Files
superwork/vscode/webview-ui/src/components/IssueDetailPanel.tsx
T
cruldra 410a04da77 ✨ feat(vscode): 实施和审查会话行加启动箭头
不用再把卡片拖到「实施中」「审查」列:

- 实施会话id 行在有计划文件且没实施会话时显示 ▶,等同拖进实施中列
- 审查会话id 行在有 PR 时显示 ▶,按 PR 事件那套流程起 codex 审查

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:52:14 +08:00

797 lines
36 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 { 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 <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
/**
* 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 <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') => 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
/** 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,
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<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
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
const secondaryActions: RowAction[] = []
if (issue?.column !== 'done') {
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)
},
})
}
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 在两种状态下
// 给出不同的提示文案。
{
key: 'implementSessionId',
label: '实施会话id',
type: 'action',
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) => {
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: <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),
}]
})(),
},
issue?.reviewSessionFileExists
? {
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
? '本机没有该 codex 会话文件,无法 resume;点 ▶ 对当前 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 为空 + 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: <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)
},
})
}
return {
key: 'testSessionId',
label: '测试会话id',
type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false,
description: hasSession
? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
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,
}
})(),
{
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 的 <!-- 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: '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, 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<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,
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 (
<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)
}}
/>
</div>
</div>
)
}