import type { ClaudeProfile } from '../hooks/useIssues' import type { Issue, IssueColumn } from '../types' import type { PropertyGroup, RowAction } from './property-grid' import { ArrowRightLeft, Check, CircleSlash, Download, Eraser, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, Undo2, UserPlus, X } from 'lucide-react' import { useMemo, useRef } from 'react' import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from '../../../src/panel/handlers/handoffRequest' import { isIssueLocked } from '../lib/dependencies' import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership' import { isSessionMissing } from '../lib/missingSession' import { toReadOnlySchema } from '../lib/readOnlySchema' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { PropertyGrid } from './property-grid' /** 会话 id 在共享状态里、本机却没有会话文件时,各会话行的统一说明。 */ const MISSING_SESSION_DESC = '本机没有该会话文件(会话在别的机器上创建),无法 resume' /** 跨机绝对路径只比 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 /** * Open a new terminal that runs `claude --resume ` for the prototype * conversation associated with this issue. */ onResumePrototypeSession: (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 /** * Spawn a fresh "原型" cc tab. Bound to the Play button next to the * "原型会话 id" row;不依赖 PR,任何阶段都能起。 */ onStartPrototypeSession: (issueNumber: number) => void /** Open a workspace-relative file in the editor. */ onOpenFile: (path: string) => void /** * 把工单的 Figma 插件 manifest 导进本机 Figma 桌面版并拉起 Figma。 * 「原型插件路径」行尾那个按钮。 */ onOpenPrototypeInFigma: (issueNumber: number, 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' | 'prototype') => void /** * 「▶ 续聊」:由插件把续聊提示词送进头脑风暴会话——规划 tab 开着就直接 * 打进去,关了就 `claude --resume ` 带着提示词恢复,没会话就新开一个。 */ onContinueBrainstorm: (issueNumber: number) => void /** 清除某行的会话 id:扩展侧弹确认框,只清本机记录,之后该行可新起一个。 */ onClearSession: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype') => void /** Persist a per-issue `autoReview` override into the state JSON. */ onUpdateAutoReview: (issueNumber: number, value: boolean) => void /** 审查列手动标记通过。 */ onUpdateReviewPassed: (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 /** Persist a per-issue `prototypeProfilePath` override into the state JSON. */ onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void /** Open the in-webview log modal. */ onOpenLogs: () => void /** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */ me?: string onStartHandoff: (issueNumber: number) => void onAcceptHandoff: (issueNumber: number) => void onAskHandoff: (issueNumber: number) => void onCancelHandoffRequest: (issueNumber: number) => void onAcceptHandoffRequest: (issueNumber: number) => void onRejectHandoffRequest: (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, onResumePrototypeSession, onStartReviewSession, onStartTestSession, onStartPrototypeSession, onOpenFile, onOpenPrototypeInFigma, onImplement, onOpenPr, onGeneratePrDiffSummary, isPrDiffSummaryRunning, onOpenWorktree, onDeleteWorktree, onMergeBranch, onDeleteIssue, onCloseIssue, onResetToTodo, onCloseSessionTab, onContinueBrainstorm, onClearSession, onUpdateAutoReview, onUpdateReviewPassed, profiles, onUpdateProfilePath, onUpdateBrainstormProfilePath, onUpdateTestProfilePath, onUpdatePrototypeProfilePath, onOpenLogs, me, isSessionTabPending, onStartHandoff, onAcceptHandoff, onAskHandoff, onCancelHandoffRequest, onAcceptHandoffRequest, onRejectHandoffRequest, 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 } // 五行会话 id 同一规则:有 id 就给 Eraser,排在该行按钮最后。会话文件在别的 // 机器上时,resume 不了的旧 id 靠它清掉,才能新起一个。 const clearSessionAction = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype'): RowAction => ({ icon: , title: '清除会话 id:只清本机记录,之后可新起一个', onClick: () => onClearSession(issueNumber, kind), }) 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 // - 有会话 id → 最后再加一个 Eraser 清除本机记录 // - 会话 id 本机没有会话文件 → 只读不可双击,续聊会新开会话 const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) const missing = isSessionMissing(issue, issue?.sessionId) const canContinue = issue?.column !== 'done' const secondaryActions: RowAction[] = [] if (canContinue) { 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 && issue) secondaryActions.push(clearSessionAction(issue.number, 'brainstorm')) if (missing) { return { key: 'sessionId', label: '头脑风暴会话id', type: 'action' as const, readOnly: true, description: canContinue ? `${MISSING_SESSION_DESC};点 ▶ 续聊会新开一个规划会话` : MISSING_SESSION_DESC, secondaryActions, } } 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 在两种状态下 // 给出不同的提示文案。 // 有会话 id 就在最后加 Eraser 清除本机记录。本机没有会话文件时只读且 // 不直接给 ▶:重新实施会先删远程 feature 分支,会把别的机器上的实施 // 进度一并抹掉;确要重来得先用 Eraser 清掉 id,▶ 再按无会话的规则出现。 const secondaryActions: RowAction[] = (() => { 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), }] })() if (issue?.implementSessionId) secondaryActions.push(clearSessionAction(issue.number, 'implement')) if (isSessionMissing(issue, issue?.implementSessionId)) { return { key: 'implementSessionId', label: '实施会话id', type: 'action' as const, readOnly: true, description: `${MISSING_SESSION_DESC};要在本机接着实施,请让对方把工单移交过来`, secondaryActions, } } return { key: 'implementSessionId', label: '实施会话id', type: 'action' as const, pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false, description: issue?.worktreeExists ? '双击在新终端运行 claude --resume 恢复实施对话(cwd 为 worktree)' : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0) return if (!throttleBySessionId(v)) return onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number) }, secondaryActions, } })(), issue?.reviewSessionId && !isSessionMissing(issue, issue.reviewSessionId) ? { 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'), }] : []), clearSessionAction(issue.number, 'review'), ], } : { key: 'reviewSessionId', label: '审查会话id', type: 'action', readOnly: true, description: issue?.reviewSessionId ? `${MISSING_SESSION_DESC};点 ▶ 对当前 PR 重新起一个审查会话` : '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)', secondaryActions: [ ...(issue?.pr ? [{ icon: , title: locked ? lockTitle : '启动审查会话', disabled: locked, onClick: () => onStartReviewSession(issue.number), }] : []), ...(issue?.reviewSessionId ? [clearSessionAction(issue.number, 'review')] : []), ], }, (() => { // 测试会话 id 行有三种状态(照搬头脑风暴行的三态): // 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab // 2. testSessionId 已存在 + testTabOpen=false → 显示 id,双击 resume // 3. testSessionId 为空 + 有 worktree 或有 PR → 显示 Play 启动新测试 cc tab // 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测, // 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区, // 那条路径要靠 PR 让 cc 了解合并进来的改动。 // 会话 id 本机没有会话文件 → 只读不可双击,按状态 3 的条件给 ▶ 重新起一个 // (启动会覆盖旧 testSessionId)。 // 有会话 id 时最后再加一个 Eraser 清除本机记录。 const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) const missing = isSessionMissing(issue, issue?.testSessionId) const canStart = (!hasSession || missing) && (!!issue?.worktreeExists || !!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) }, }) } if (hasSession && issue) secondaryActions.push(clearSessionAction(issue.number, 'test')) if (missing) { return { key: 'testSessionId', label: '测试会话id', type: 'action' as const, readOnly: true, description: `${MISSING_SESSION_DESC};点 ▶ 新起一个测试会话(worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR)`, secondaryActions, } } return { key: 'testSessionId', label: '测试会话id', type: 'action' as const, pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false, description: hasSession ? '双击在新终端运行 claude --resume 恢复测试对话(worktree 在则用之,否则工作区根)' : '点击右侧 ▶ 启动测试 cc tab(让 cc 了解代码后告诉你怎么测试):worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0 || !issue) return if (!throttleBySessionId(v)) return onResumeTestSession(v, issue.number) }, secondaryActions, } })(), (() => { // 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照 // 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。 // 会话 id 本机没有会话文件 → 只读不可双击,同样给 ▶ 重新起一个 // (启动会覆盖旧 prototypeSessionId)。有会话 id 时最后再加一个 Eraser // 清除本机记录。 const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0) const missing = isSessionMissing(issue, issue?.prototypeSessionId) const secondaryActions: RowAction[] = [] if (hasSession && issue?.prototypeTabOpen) { secondaryActions.push({ icon: , title: '关闭原型 tab', onClick: () => { if (issue) onCloseSessionTab(issue.number, 'prototype') }, }) } else if (!hasSession || missing) { secondaryActions.push({ icon: , title: locked ? lockTitle : '启动原型会话', disabled: locked, onClick: () => { if (issue) onStartPrototypeSession(issue.number) }, }) } if (hasSession && issue) secondaryActions.push(clearSessionAction(issue.number, 'prototype')) if (missing) { return { key: 'prototypeSessionId', label: '原型会话id', type: 'action' as const, readOnly: true, description: `${MISSING_SESSION_DESC};点 ▶ 新起一个原型会话`, secondaryActions, } } return { key: 'prototypeSessionId', label: '原型会话id', type: 'action' as const, pending: hasSession ? isSessionTabPending(issue!.prototypeSessionId!) : false, description: hasSession ? '双击在新终端运行 claude --resume 恢复原型对话(worktree 在则用之,否则工作区根目录)' : '点击右侧 ▶ 启动一个原型 cc tab:照工单正文画界面原型,不依赖 PR', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0 || !issue) return if (!throttleBySessionId(v)) return onResumePrototypeSession(v, issue.number) }, secondaryActions, } })(), { key: 'autoReview', label: '自动审查', type: 'boolean', description: `打开新 PR 时是否自动启动 codex 审查;未设置时跟随全局(当前全局:${globalAutoReview ? '开' : '关'})`, }, ...(issue?.column === 'review' && issue.source !== 'youtrack' ? [{ key: 'reviewPassed', label: '审查通过', type: 'boolean' as const, description: '手动标成已通过,标题变绿。新的提交会清掉这个标记。', }] : []), { 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: 'prototypeProfilePath', label: '原型配置文件', type: 'select', options: (() => { const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))] const current = matchProfileOption(issue?.prototypeProfilePath, 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: 'prototypeFile', label: '原型插件路径', type: 'file-link', description: '双击在编辑器打开 Figma 插件的 manifest.json;点右侧按钮把插件导入本机 Figma 并打开;cc 通过 issue body 的 注释同步', onOpen: (p: string) => onOpenFile(p), // 没挂原型插件时整个按钮不渲染,行只显示空态。 secondaryActions: issue?.prototypeFile ? [{ icon: , title: '在 Figma 中导入并打开', onClick: () => { if (issue?.prototypeFile) onOpenPrototypeInFigma(issue.number, issue.prototypeFile) }, }] : [], }, { 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, onResumePrototypeSession, onStartPrototypeSession, onOpenFile, onOpenPrototypeInFigma, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearSession, 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, prototypeSessionId: issue.prototypeSessionId ?? null, autoReview: issue.autoReview ?? globalAutoReview, reviewPassed: issue.reviewPassed === true, color: issue.color ?? null, // 展示/选中值按 basename 映射到本机 profiles,避免跨机绝对路径变成「自定义」项。 brainstormProfilePath: matchProfileOption(issue.brainstormProfilePath, profiles) || null, profilePath: matchProfileOption(issue.profilePath, profiles) || null, testProfilePath: matchProfileOption(issue.testProfilePath, profiles) || null, prototypeProfilePath: matchProfileOption(issue.prototypeProfilePath, profiles) || null, specFile: issue.specFile ?? null, planFile: issue.planFile ?? null, prototypeFile: issue.prototypeFile ?? 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.slice(0, 1)}} ) : null} {canAskHandoff(issue, me) && ( )} {canCancelHandoffRequest(issue, me) && ( )} {canAnswerHandoffRequest(issue, me) && ( <> )} {/* 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 === 'reviewPassed' && issue && typeof value === 'boolean') onUpdateReviewPassed(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) if (key === 'prototypeProfilePath' && issue && typeof value === 'string') onUpdatePrototypeProfilePath(issue.number, value) }} />
) }