import type { ClaudeProfile } from '../hooks/useIssues' import type { Issue, IssueColumn } from '../types' import type { PropertyGroup, RowAction } from './property-grid' import { ArrowRightLeft, CircleSlash, Download, Eraser, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react' import { useMemo, useRef } from 'react' import { isIssueLocked } from '../lib/dependencies' import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership' import { toReadOnlySchema } from '../lib/readOnlySchema' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { PropertyGrid } from './property-grid' import { Tooltip } from './ui/tooltip' /** 跨机绝对路径只比 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, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void /** 会话 id 行的 spinner:该会话的开 tab 请求还没被扩展处理完就为 true。 */ isSessionTabPending: (sessionId: string) => boolean /** * 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). */ onStartReviewSession: (issueNumber: number) => void 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 /** * 「▶ 续聊」:由插件把续聊提示词送进头脑风暴会话——规划 tab 开着就直接 * 打进去,关了就 `claude --resume ` 带着提示词恢复,没会话就新开一个。 */ onContinueBrainstorm: (issueNumber: number) => void onClearBrainstormSession: (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 /** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */ avatars?: Record } /** * 底部详情面板:以 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, onStartReviewSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, isPrDiffSummaryRunning, onOpenWorktree, onDeleteWorktree, onMergeBranch, onDeleteIssue, onCloseIssue, onResetToTodo, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onUpdateAutoReview, profiles, onUpdateProfilePath, onUpdateBrainstormProfilePath, onUpdateTestProfilePath, onOpenLogs, me, isSessionTabPending, onStartHandoff, onAcceptHandoff, isHandoffRunning, avatars, }: 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 行: // - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话: // 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。 // - brainstormTabOpen → 再加一个 X 关闭 tab const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) const secondaryActions: RowAction[] = [] if (issue?.column !== 'done') { secondaryActions.push({ icon: , title: '续聊:把续聊提示词发给头脑风暴会话', onClick: () => { if (issue) onContinueBrainstorm(issue.number) }, }) } if (hasSession && issue?.brainstormTabOpen) { secondaryActions.push({ icon: , title: '关闭头脑风暴 tab', onClick: () => { if (issue) onCloseSessionTab(issue.number, 'brainstorm') }, }) } if (hasSession) { secondaryActions.push({ icon: , title: '清除会话:下次续聊新开一个,不再带旧会话的历史', onClick: () => { if (issue) onClearBrainstormSession(issue.number) }, }) } return { key: 'sessionId', label: '头脑风暴会话id', type: 'action' as const, pending: hasSession ? isSessionTabPending(issue!.sessionId!) : false, description: hasSession ? '双击在新终端运行 claude --resume 恢复对话;点 ▶ 续聊' : '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0) return if (!throttleBySessionId(v)) return onResumeSession(v, 'brainstorm', issue?.brainstormProfilePath, undefined, issue?.number) }, secondaryActions, } })(), // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口: // extension 端 handleResumeSession 会在 worktree 路径不存在时退回 // 工作区根目录,并通过 toast 告知用户;description 在两种状态下 // 给出不同的提示文案。 { key: 'implementSessionId', label: '实施会话id', type: 'action', pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false, 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, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number) }, secondaryActions: (() => { if (issue?.implementTabOpen) { return [{ icon: , title: '关闭实施 tab', onClick: () => onCloseSessionTab(issue.number, 'implement'), }] } // 还没实施会话时给个 ▶,省去把卡片拖进「实施中」列。 if (issue?.implementSessionId || !issue?.planFile) return [] return [{ icon: , title: locked ? lockTitle : '启动实施会话', disabled: issue.implementStatus === 'running' || issue.implementStatus === 'done' || locked, onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId), }] })(), }, issue?.reviewSessionFileExists ? { key: 'reviewSessionId', label: '审查会话id', type: 'action', pending: issue.reviewSessionId ? isSessionTabPending(issue.reviewSessionId) : false, 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) }, secondaryActions: issue.reviewTabOpen ? [{ icon: , title: '关闭审查 tab', onClick: () => onCloseSessionTab(issue.number, 'review'), }] : [], } : { key: 'reviewSessionId', label: '审查会话id', type: 'action', readOnly: true, description: issue?.reviewSessionId ? '本机没有该 codex 会话文件,无法 resume;点 ▶ 对当前 PR 重新起一个审查会话' : '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)', secondaryActions: issue?.pr ? [{ icon: , title: locked ? lockTitle : '启动审查会话', disabled: locked, onClick: () => onStartReviewSession(issue.number), }] : [], }, (() => { // 测试会话 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 const secondaryActions: RowAction[] = [] if (hasSession && issue?.testTabOpen) { secondaryActions.push({ icon: , title: '关闭测试 tab', onClick: () => { if (issue) onCloseSessionTab(issue.number, 'test') }, }) } else if (canStart) { secondaryActions.push({ icon: , title: '启动测试会话', onClick: () => { if (issue) onStartTestSession(issue.number) }, }) } return { key: 'testSessionId', label: '测试会话id', type: 'action' as const, pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false, 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) }, secondaryActions, } })(), { 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. secondaryActions: issue?.planFile ? [{ icon: , title: 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. // 锁定态(前置工单未完成)同样禁止启动实施。 disabled: issue.implementStatus === 'running' || issue.implementStatus === 'done' || locked, onClick: () => { 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) }, secondaryActions: issue?.pr && !issue?.prDiffFile ? [{ icon: , title: prDiffSummaryRunning ? 'PR 变更摘要生成中' : '生成 PR 变更摘要', disabled: prDiffSummaryRunning, onClick: () => { 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: '实施流程创建的分支名', secondaryActions: issue?.branch ? [{ icon: , title: '本地预合并 (git merge --no-commit --no-ff)', onClick: () => { 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), secondaryActions: [{ icon: , title: '删除 worktree', onClick: () => onDeleteWorktree(issue.number, issue.worktreePath ?? ''), }], } : { key: 'worktreePath', label: '工作树', type: 'string', readOnly: true, description: '实施流程创建的 git worktree 路径(workspace 相对)', }, ], }, ], [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles, isSessionTabPending, onStartReviewSession], ) // 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。 const readOnly = issue ? !isIssueMine(issue, me) : false const ownerLogin = issue ? issueOwnerLogin(issue) : undefined const effectiveSchema = useMemo( () => readOnly ? toReadOnlySchema(schema) : schema, [schema, readOnly], ) 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}

{readOnly && ownerLogin ? ( {avatars?.[ownerLogin] ? {ownerLogin} : null} {ownerLogin} {' · 只读'} ) : 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' && !readOnly && ( <> {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && ( )} {!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && ( )} {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 (readOnly) return 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) }} />
) }