Files
superwork/vscode/webview-ui/src/components/IssueDetailPanel.tsx
T

963 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { isSessionMissing } from '../lib/missingSession'
import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid'
import { Tooltip } from './ui/tooltip'
/** 会话 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 <id>` 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 <id>` for the test
* conversation associated with this issue.
*/
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
/**
* Open a new terminal that runs `claude --resume <id>` 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 <branch>`) 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 <id>` 带着提示词恢复,没会话就新开一个。
*/
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
/** 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
isHandoffRunning: (issueNumber: number) => boolean
/** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
avatars?: Record<string, string>
}
/**
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
*
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
* 一个 action 类型字段,双击或选中卡按 Enter 都会触发
* `claude --resume <id>` 在新终端中恢复对话。
*/
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,
onClearBrainstormSession,
onUpdateAutoReview,
profiles,
onUpdateProfilePath,
onUpdateBrainstormProfilePath,
onUpdateTestProfilePath,
onUpdatePrototypeProfilePath,
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<Map<string, number>>(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<PropertyGroup[]>(
() => [
{
id: 'state',
label: 'state JSON',
actions: (
<button
type="button"
onClick={onOpenLogs}
title="查看扩展日志"
aria-label="查看扩展日志"
className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
>
<Terminal className="size-3.5" />
</button>
),
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 本机没有会话文件 → 只读不可双击,续聊会新开会话
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: <Play className="size-3.5" />,
title: '续聊:把续聊提示词发给头脑风暴会话',
onClick: () => {
if (issue)
onContinueBrainstorm(issue.number)
},
})
}
if (hasSession && issue?.brainstormTabOpen) {
secondaryActions.push({
icon: <X className="size-3.5" />,
title: '关闭头脑风暴 tab',
onClick: () => {
if (issue)
onCloseSessionTab(issue.number, 'brainstorm')
},
})
}
if (hasSession) {
secondaryActions.push({
icon: <Eraser className="size-3.5" />,
title: '清除会话:下次续聊新开一个,不再带旧会话的历史',
onClick: () => {
if (issue)
onClearBrainstormSession(issue.number)
},
})
}
if (missing) {
return {
key: 'sessionId',
label: '头脑风暴会话id',
type: 'action' as const,
readOnly: true,
description: canContinue ? `${MISSING_SESSION_DESC};点 ▶ 续聊会新开一个规划会话` : MISSING_SESSION_DESC,
actionIcon: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return {
key: 'sessionId',
label: '头脑风暴会话id',
type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.sessionId!) : false,
description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复对话;点 ▶ 续聊'
: '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊',
actionIcon: <Terminal className="size-3.5" />,
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 在两种状态下
// 给出不同的提示文案。
// 本机没有会话文件时只读且不给 ▶:重新实施会先删远程 feature 分支,
// 会把别的机器上的实施进度一并抹掉。
const secondaryActions: RowAction[] = (() => {
if (issue?.implementTabOpen) {
return [{
icon: <X className="size-3.5" />,
title: '关闭实施 tab',
onClick: () => onCloseSessionTab(issue.number, 'implement'),
}]
}
// 还没实施会话时给个 ▶,省去把卡片拖进「实施中」列。
if (issue?.implementSessionId || !issue?.planFile)
return []
return [{
icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '启动实施会话',
disabled:
issue.implementStatus === 'running'
|| issue.implementStatus === 'done'
|| locked,
onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId),
}]
})()
if (isSessionMissing(issue, issue?.implementSessionId)) {
return {
key: 'implementSessionId',
label: '实施会话id',
type: 'action' as const,
readOnly: true,
description: `${MISSING_SESSION_DESC};要在本机接着实施,请让对方把工单移交过来`,
actionIcon: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return {
key: 'implementSessionId',
label: '实施会话id',
type: 'action' as const,
pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false,
description: issue?.worktreeExists
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
actionIcon: <Terminal className="size-3.5" />,
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 <id> 查看审查会话(worktree 在则用之,否则工作区根目录)',
actionIcon: <Terminal className="size-3.5" />,
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: <X className="size-3.5" />,
title: '关闭审查 tab',
onClick: () => onCloseSessionTab(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: <Play className="size-3.5" />,
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 为空 + 有 worktree 或有 PR → 显示 Play 启动新测试 cc tab
// 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测,
// 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区,
// 那条路径要靠 PR 让 cc 了解合并进来的改动。
// 会话 id 本机没有会话文件 → 只读不可双击,按状态 3 的条件给 ▶ 重新起一个
// (启动会覆盖旧 testSessionId)。
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: <X className="size-3.5" />,
title: '关闭测试 tab',
onClick: () => {
if (issue)
onCloseSessionTab(issue.number, 'test')
},
})
}
else if (canStart) {
secondaryActions.push({
icon: <Play className="size-3.5" />,
title: '启动测试会话',
onClick: () => {
if (issue)
onStartTestSession(issue.number)
},
})
}
if (missing) {
return {
key: 'testSessionId',
label: '测试会话id',
type: 'action' as const,
readOnly: true,
description: `${MISSING_SESSION_DESC};点 ▶ 新起一个测试会话(worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR)`,
actionIcon: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return {
key: 'testSessionId',
label: '测试会话id',
type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false,
description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复测试对话(worktree 在则用之,否则工作区根)'
: '点击右侧 ▶ 启动测试 cc tab(让 cc 了解代码后告诉你怎么测试):worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR',
actionIcon: <Terminal className="size-3.5" />,
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)。
const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0)
const missing = isSessionMissing(issue, issue?.prototypeSessionId)
const secondaryActions: RowAction[] = []
if (hasSession && issue?.prototypeTabOpen) {
secondaryActions.push({
icon: <X className="size-3.5" />,
title: '关闭原型 tab',
onClick: () => {
if (issue)
onCloseSessionTab(issue.number, 'prototype')
},
})
}
else if (!hasSession || missing) {
secondaryActions.push({
icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '启动原型会话',
disabled: locked,
onClick: () => {
if (issue)
onStartPrototypeSession(issue.number)
},
})
}
if (missing) {
return {
key: 'prototypeSessionId',
label: '原型会话id',
type: 'action' as const,
readOnly: true,
description: `${MISSING_SESSION_DESC};点 ▶ 新起一个原型会话`,
actionIcon: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return {
key: 'prototypeSessionId',
label: '原型会话id',
type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.prototypeSessionId!) : false,
description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复原型对话(worktree 在则用之,否则工作区根目录)'
: '点击右侧 ▶ 启动一个原型 cc tab:照工单正文画界面原型,不依赖 PR',
actionIcon: <Terminal className="size-3.5" />,
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 ? '开' : '关'})`,
},
{
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 的 <!-- spx:spec=路径 --> 注释实时同步',
onOpen: (p: string) => onOpenFile(p),
},
{
key: 'planFile',
label: '计划文件',
type: 'file-link',
description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
onOpen: (p: string) => onOpenFile(p),
// Hide the "实施" button entirely when there's no plan file.
secondaryActions: issue?.planFile
? [{
icon: <Play className="size-3.5" />,
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 的 <!-- spx:prototype=路径 --> 注释同步',
onOpen: (p: string) => onOpenFile(p),
// 没挂原型插件时整个按钮不渲染,行只显示空态。
secondaryActions: issue?.prototypeFile
? [{
icon: <ExternalLink className="size-3.5" />,
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: <Play className="size-3.5" />,
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: <GitMerge className="size-3.5" />,
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: <Trash2 className="size-3.5" />,
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, 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<Record<string, unknown> | 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,
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 (
<div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
按方向键或点击卡片查看详情
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col gap-3 border-t border-[var(--vscode-panel-border)] p-3">
<div className="flex items-center gap-3">
<h3 className="flex-1 truncate text-sm font-medium">
<span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
</span>
{issue.source === 'youtrack' && (
<span
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
title="此工单来自 YouTrack"
>
YouTrack
</span>
)}
{' '}
{issue.title}
{locked && prerequisiteNumber != null
? (
<span
className="ml-2 inline-flex items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 align-middle text-[10px] font-normal text-[var(--vscode-disabledForeground)]"
title={lockTitle}
>
等待 #
{prerequisiteNumber}
{' '}
完成
</span>
)
: null}
</h3>
{readOnly && ownerLogin
? (
<span
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-[11px] opacity-80"
title={`${ownerLogin} 的工单,只能查看`}
>
{avatars?.[ownerLogin]
? <img src={avatars[ownerLogin]} alt={ownerLogin} className="size-4 rounded-full" />
: null}
{ownerLogin}
{' · 只读'}
</span>
)
: null}
<Tooltip label="在浏览器里打开">
<button
type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
</button>
</Tooltip>
{/* 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) && (
<Tooltip label={`接管 ${issue.handoffFrom ?? '?'} 的移交`}>
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onAcceptHandoff(issue.number)}
aria-label="接管"
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
接管
</button>
</Tooltip>
)}
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
<Tooltip label="移交给同事">
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onStartHandoff(issue.number)}
aria-label="移交"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button>
</Tooltip>
)}
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
<Tooltip label={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70">
待接管
</span>
</Tooltip>
)}
{(issue.column === 'in-progress' || issue.column === 'review') && (
<Tooltip label="重置为待办(停会话、删 worktree 和未合并 PR)">
<button
type="button"
onClick={() => onResetToTodo(issue.number)}
aria-label="重置为待办"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<RotateCcw className="size-3.5" />
</button>
</Tooltip>
)}
<Tooltip label="关闭工单(Gitea 里可重开)">
<button
type="button"
onClick={() => onCloseIssue(issue.number)}
aria-label="关闭工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<CircleSlash className="size-3.5" />
</button>
</Tooltip>
<Tooltip label="删除工单(不可撤销)">
<button
type="button"
onClick={() => issue && onDeleteIssue?.(issue.number)}
aria-label="删除工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 className="size-3.5" />
</button>
</Tooltip>
</>
)}
</div>
{issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && (
<div className="flex flex-col gap-1 text-xs">
<span className="opacity-60">
附件(
{issue.attachments.length}
)
</span>
<div className="flex flex-wrap gap-2">
{issue.attachments.map(att => (
<button
key={att.url}
type="button"
onClick={() => onOpenInBrowser(att.url)}
title={att.name}
className="inline-flex max-w-[200px] items-center gap-1 truncate rounded border border-[var(--vscode-panel-border)] px-2 py-1 hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3 shrink-0" />
<span className="truncate">{att.name}</span>
</button>
))}
</div>
</div>
)}
<div className="min-h-0 flex-1">
<PropertyGrid
key={issue.number}
schema={effectiveSchema}
data={data}
hideToolbar
fillHeight
onChange={(key, value) => {
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)
if (key === 'prototypeProfilePath' && issue && typeof value === 'string')
onUpdatePrototypeProfilePath(issue.number, value)
}}
/>
</div>
</div>
)
}