import type { ReactNode } from 'react' import { useMemo, useRef } from 'react' import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react' import type { ClaudeProfile } from '../hooks/useIssues' import type { Issue, IssueColumn } from '../types' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { isIssueLocked } from '../lib/dependencies' import type { PropertyGroup } from './property-grid' import { PropertyGrid } from './property-grid' /** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */ function matchProfileOption( current: string | undefined, profiles: ClaudeProfile[], ): string { const raw = current?.trim() if (!raw) return '' if (profiles.some(p => p.path === raw)) return raw const base = raw.split(/[/\\]/).pop() ?? '' if (!base) return raw const byFile = profiles.find(p => p.path.split(/[/\\]/).pop() === base) if (byFile) return byFile.path const name = base.endsWith('.json') ? base.slice(0, -'.json'.length) : base const byName = profiles.find(p => p.name === name) return byName?.path ?? raw } interface IssueDetailPanelProps { issue: Issue | null /** 当前所有 issues,用于解析前置依赖锁定态。 */ allIssues: Issue[] /** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */ globalAutoReview: boolean onOpenInBrowser: (url: string) => void onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void /** Open a new terminal that runs `codex resume ` for the auto-review * conversation associated with this issue. */ onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void /** Open a new terminal that runs `claude --resume ` for the test * conversation associated with this issue. */ onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void /** Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the * Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). */ onStartTestSession: (issueNumber: number) => void /** Open a workspace-relative file in the editor. */ onOpenFile: (path: string) => void /** Kick off the end-to-end implementation flow for the given plan file. */ onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void /** Open the gitea PR page in the browser. */ onOpenPr: (pr: string) => void /** Start a background Claude run that writes the PR diff summary markdown. */ onGeneratePrDiffSummary: (issueNumber: number) => void /** Whether a PR diff summary generation request is in flight for an issue. */ isPrDiffSummaryRunning: (issueNumber: number) => boolean /** Open the workspace-relative worktree path in a new VS Code window. */ onOpenWorktree: (path: string) => void /** Delete the worktree (`git worktree remove`) and clear it from state. */ onDeleteWorktree: (issueNumber: number, path: string) => void /** Pre-merge the issue's feature branch into the main worktree's current branch * (`git merge --no-commit --no-ff `) so the user can inspect locally. */ onMergeBranch: (issueNumber: number, branch: string) => void /** 硬删整个工单 + 关联资源(worktree / PR / feature branch / cc tabs)。 * 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。 */ onDeleteIssue?: (issueNumber: number) => void /** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */ onCloseIssue: (issueNumber: number) => void /** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */ onResetToTodo: (issueNumber: number) => void /** Close the matching session terminal tab. The extension watches * onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which * makes the X button vanish on its own. */ onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void /** Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty * (created out-of-band via spx without webhook linking). Bound to the Play * button next to the "头脑风暴会话 id" row. */ onStartBrainstormSession: (issueNumber: number) => void /** Persist a per-issue `autoReview` override into the state JSON. */ onUpdateAutoReview: (issueNumber: number, value: boolean) => void /** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */ profiles: ClaudeProfile[] /** Persist a per-issue `profilePath` override into the state JSON. */ onUpdateProfilePath: (issueNumber: number, profilePath: string) => void /** Persist a per-issue `brainstormProfilePath` override into the state JSON. */ onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void /** Persist a per-issue `testProfilePath` override into the state JSON. */ onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void /** Open the in-webview log modal. */ onOpenLogs: () => void /** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */ me?: string onStartHandoff: (issueNumber: number) => void onAcceptHandoff: (issueNumber: number) => void isHandoffRunning: (issueNumber: number) => boolean } /** * 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。 * * 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid * 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是 * 一个 action 类型字段,双击或选中卡按 Enter 都会触发 * `claude --resume ` 在新终端中恢复对话。 */ export function IssueDetailPanel({ issue, allIssues, globalAutoReview, onOpenInBrowser, onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, isPrDiffSummaryRunning, onOpenWorktree, onDeleteWorktree, onMergeBranch, onDeleteIssue, onCloseIssue, onResetToTodo, onCloseSessionTab, onStartBrainstormSession, onUpdateAutoReview, profiles, onUpdateProfilePath, onUpdateBrainstormProfilePath, onUpdateTestProfilePath, onOpenLogs, me, onStartHandoff, onAcceptHandoff, isHandoffRunning, }: IssueDetailPanelProps) { // 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮, // 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。 const { locked, prerequisiteNumber } = issue ? isIssueLocked(issue, allIssues) : { locked: false, prerequisiteNumber: undefined as number | undefined } const lockTitle = locked && prerequisiteNumber != null ? `等待 #${prerequisiteNumber} 完成` : undefined const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false // 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表, // 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal // 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。 // key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。 const lastFiredAtRef = useRef>(new Map()) const RESUME_THROTTLE_MS = 800 const throttleBySessionId = (sid: string): boolean => { const now = Date.now() const last = lastFiredAtRef.current.get(sid) ?? 0 if (now - last < RESUME_THROTTLE_MS) return false lastFiredAtRef.current.set(sid, now) return true } const schema = useMemo( () => [ { id: 'state', label: 'state JSON', actions: ( ), properties: [ { key: 'column', label: '状态', type: 'select', readOnly: true, description: '工单所在的看板列;写入 issue 最后一条 JSON 评论的 column 字段', options: COLUMN_ORDER.map((c: IssueColumn) => ({ label: `${c}(${COLUMN_LABELS[c]})`, value: c, })), }, (() => { // 头脑风暴会话 id 行有三种状态: // 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab // 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮) // 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab) // 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮 const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) const canStart = !hasSession && issue?.column !== 'done' let secondaryActionIcon: ReactNode | undefined let secondaryActionTitle: string | undefined let onSecondaryAction: (() => void) | undefined if (hasSession && issue?.brainstormTabOpen) { secondaryActionIcon = secondaryActionTitle = '关闭头脑风暴 tab' onSecondaryAction = () => { if (issue) onCloseSessionTab(issue.number, 'brainstorm') } } else if (canStart) { secondaryActionIcon = secondaryActionTitle = '启动头脑风暴会话' onSecondaryAction = () => { if (issue) onStartBrainstormSession(issue.number) } } return { key: 'sessionId', label: '头脑风暴会话id', type: 'action' as const, description: hasSession ? '双击在新终端运行 claude --resume 恢复对话' : '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0) return if (!throttleBySessionId(v)) return onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number) }, secondaryActionIcon, secondaryActionTitle, onSecondaryAction, } })(), // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口: // extension 端 handleResumeSession 会在 worktree 路径不存在时退回 // 工作区根目录,并通过 toast 告知用户;description 在两种状态下 // 给出不同的提示文案。 { key: 'implementSessionId', label: '实施会话id', type: 'action', description: issue?.worktreeExists ? '双击在新终端运行 claude --resume 恢复实施对话(cwd 为 worktree)' : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', actionIcon: , onAction: (v) => { if (typeof v !== 'string' || v.length === 0) return if (!throttleBySessionId(v)) return onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number) }, secondaryActionIcon: issue?.implementTabOpen ? : undefined, secondaryActionTitle: '关闭实施 tab', onSecondaryAction: () => { if (issue) onCloseSessionTab(issue.number, 'implement') }, }, issue?.reviewSessionFileExists ? { key: 'reviewSessionId', label: '审查会话id', type: 'action', description: '双击在新终端运行 codex resume 查看审查会话(worktree 在则用之,否则工作区根目录)', actionIcon: , onAction: (v) => { if (typeof v !== 'string' || v.length === 0 || !issue) return if (!throttleBySessionId(v)) return onResumeReviewSession(v, issue.number, issue?.worktreePath) }, secondaryActionIcon: issue?.reviewTabOpen ? : undefined, secondaryActionTitle: '关闭审查 tab', onSecondaryAction: () => { if (issue) onCloseSessionTab(issue.number, 'review') }, } : { key: 'reviewSessionId', label: '审查会话id', type: 'string', readOnly: true, description: '本机没有该 codex 会话文件,无法 resume;仅保留 id 文本', }, (() => { // 测试会话 id 行有三种状态(照搬头脑风暴行的三态): // 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab // 2. testSessionId 已存在 + testTabOpen=false → 显示 id,双击 resume // 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab // (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮) const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) const canStart = !hasSession && !!issue?.pr let secondaryActionIcon: ReactNode | undefined let secondaryActionTitle: string | undefined let onSecondaryAction: (() => void) | undefined if (hasSession && issue?.testTabOpen) { secondaryActionIcon = secondaryActionTitle = '关闭测试 tab' onSecondaryAction = () => { if (issue) onCloseSessionTab(issue.number, 'test') } } else if (canStart) { secondaryActionIcon = secondaryActionTitle = '启动测试会话' onSecondaryAction = () => { if (issue) onStartTestSession(issue.number) } } return { key: 'testSessionId', label: '测试会话id', type: 'action' as const, description: hasSession ? '双击在新终端运行 claude --resume 在工作区根(main)恢复测试对话' : '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0 || !issue) return if (!throttleBySessionId(v)) return onResumeTestSession(v, issue.number) }, secondaryActionIcon, secondaryActionTitle, onSecondaryAction, } })(), { key: 'autoReview', label: '自动审查', type: 'boolean', description: `打开新 PR 时是否自动启动 codex 审查;未设置时跟随全局(当前全局:${globalAutoReview ? '开' : '关'})`, }, { key: 'color', label: '颜色', type: 'theme-color', readOnly: true, description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色', }, { key: 'brainstormProfilePath', label: '头脑风暴配置文件', type: 'select', options: (() => { // 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何 // option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发 // onChange、永远存不进去;占位项让未设置态有真实匹配项。 // 跨机绝对路径按 basename 对齐本机 profiles,避免「自定义:/Users/...」。 const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] const current = matchProfileOption(issue?.brainstormProfilePath, profiles) if (current && !opts.some(o => o.value === current)) opts.push({ label: `自定义:${current}`, value: current }) return opts })(), description: '头脑风暴会话使用的 Claude 配置文件;选「(默认)」用内置默认,与实施/测试互不回退', }, { key: 'profilePath', label: '实施配置文件', type: 'select', options: (() => { const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] const current = matchProfileOption(issue?.profilePath, profiles) if (current && !opts.some(o => o.value === current)) opts.push({ label: `自定义:${current}`, value: current }) return opts })(), description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/测试互不回退', }, { key: 'testProfilePath', label: '测试配置文件', type: 'select', options: (() => { const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] const current = matchProfileOption(issue?.testProfilePath, profiles) if (current && !opts.some(o => o.value === current)) opts.push({ label: `自定义:${current}`, value: current }) return opts })(), description: '测试会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/实施互不回退', }, { key: 'specFile', label: '规格文件', type: 'file-link', description: '双击文件名在编辑器打开;cc 通过 issue body 的 注释实时同步', onOpen: (p: string) => onOpenFile(p), }, { key: 'planFile', label: '计划文件', type: 'file-link', description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 注释实时同步', onOpen: (p: string) => onOpenFile(p), // Hide the "实施" button entirely when there's no plan file. secondaryActionIcon: issue?.planFile ? : undefined, secondaryActionTitle: locked ? lockTitle : '实施此计划', // Disable while running or already done. The user can still click // when status === 'failed' or never been run. Re-clicking while // 'running' is blocked at the UI to avoid registering duplicate // webhooks. To retry after a perceived failure with status still // 'running', edit the state-JSON comment manually for now. // 锁定态(前置工单未完成)同样禁止启动实施。 secondaryDisabled: !issue?.planFile || issue?.implementStatus === 'running' || issue?.implementStatus === 'done' || locked, onSecondaryAction: () => { if (!issue?.planFile) return onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId) }, }, { key: 'pr', label: '合并请求', type: 'pr-link', description: '双击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识', onAction: (v) => { if (typeof v !== 'string' || v.length === 0) return // value 形如 "65" 或 "65(已合并)";剥掉合并后缀只把纯编号传出去。 const pr = v.replace(/\(已合并\)\s*$/, '') if (pr.length > 0) onOpenPr(pr) }, secondaryActionIcon: issue?.pr && !issue?.prDiffFile ? : undefined, secondaryActionTitle: prDiffSummaryRunning ? 'PR 变更摘要生成中' : '生成 PR 变更摘要', secondaryDisabled: prDiffSummaryRunning, onSecondaryAction: () => { if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning) onGeneratePrDiffSummary(issue.number) }, }, { key: 'prDiffFile', label: 'PR 变更摘要', type: 'file-link', description: '双击文件名在编辑器打开生成的 PR 代码变更摘要', onOpen: (p: string) => onOpenFile(p), }, { key: 'branch', label: '分支', type: 'string', readOnly: true, description: '实施流程创建的分支名', secondaryActionIcon: issue?.branch ? : undefined, secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)', onSecondaryAction: () => { if (issue?.branch) onMergeBranch(issue.number, issue.branch) }, }, issue?.worktreeExists ? { key: 'worktreePath', label: '工作树', type: 'file-link', description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree', onOpen: (p: string) => onOpenWorktree(p), onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''), secondaryActionIcon: , secondaryActionTitle: '删除 worktree', } : { key: 'worktreePath', label: '工作树', type: 'string', readOnly: true, description: '实施流程创建的 git worktree 路径(workspace 相对)', }, ], }, ], [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles], ) const data = useMemo | null>(() => { if (!issue) return null return { column: issue.column, sessionId: issue.sessionId ?? null, implementSessionId: issue.implementSessionId ?? null, reviewSessionId: issue.reviewSessionId ?? null, testSessionId: issue.testSessionId ?? null, autoReview: issue.autoReview ?? globalAutoReview, color: issue.color ?? null, // 展示/选中值按 basename 映射到本机 profiles,避免跨机绝对路径变成「自定义」项。 brainstormProfilePath: matchProfileOption(issue.brainstormProfilePath, profiles) || null, profilePath: matchProfileOption(issue.profilePath, profiles) || null, testProfilePath: matchProfileOption(issue.testProfilePath, profiles) || null, specFile: issue.specFile ?? null, planFile: issue.planFile ?? null, prDiffFile: issue.prDiffFile ?? null, pr: issue.pr ? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}` : null, branch: issue.branch ?? null, worktreePath: issue.worktreePath ?? null, } }, [ issue, globalAutoReview, profiles, ]) if (!issue || !data) { return (
按方向键或点击卡片查看详情
) } return (

{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} {issue.source === 'youtrack' && ( YouTrack )} {' '} {issue.title} {locked && prerequisiteNumber != null ? ( 等待 # {prerequisiteNumber} {' '} 完成 ) : null}

{/* Close/delete write to gitea by number — hide for youtrack cards to avoid acting on a gitea issue of the same number. */} {issue.source !== 'youtrack' && ( <> {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && ( )} {!issue.handoffAttachmentId && issue.column !== 'done' && ( )} {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( 待接管 )} {(issue.column === 'in-progress' || issue.column === 'review') && ( )} )}
{issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && (
附件({issue.attachments.length})
{issue.attachments.map(att => ( ))}
)}
{ if (key === 'autoReview' && issue && typeof value === 'boolean') onUpdateAutoReview(issue.number, value) if (key === 'brainstormProfilePath' && issue && typeof value === 'string') onUpdateBrainstormProfilePath(issue.number, value) if (key === 'profilePath' && issue && typeof value === 'string') onUpdateProfilePath(issue.number, value) if (key === 'testProfilePath' && issue && typeof value === 'string') onUpdateTestProfilePath(issue.number, value) }} />
) }