1040 lines
49 KiB
TypeScript
1040 lines
49 KiB
TypeScript
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 { trackerName } from '../lib/trackerName'
|
||
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 <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 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
|
||
/** 关闭代码托管仓库上的工单,不清理本地会话、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
|
||
/** 清除某行的会话 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
|
||
/** 当前登录者的 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
|
||
/** login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
|
||
avatars?: Record<string, string>
|
||
}
|
||
|
||
/**
|
||
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
|
||
*
|
||
* 顶部一行是 #编号/标题 + 「在 GitHub / Gitea / YouTrack 打开」按钮;下面是 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,
|
||
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<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
|
||
}
|
||
|
||
// 五行会话 id 同一规则:有 id 就给 Eraser,排在该行按钮最后。会话文件在别的
|
||
// 机器上时,resume 不了的旧 id 靠它清掉,才能新起一个。
|
||
const clearSessionAction = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype'): RowAction => ({
|
||
icon: <Eraser className="size-3.5" />,
|
||
title: '清除会话 id:只清本机记录,之后可新起一个',
|
||
onClick: () => onClearSession(issueNumber, kind),
|
||
})
|
||
|
||
const schema = useMemo<PropertyGroup[]>(
|
||
() => [
|
||
{
|
||
id: 'state',
|
||
label: 'state JSON',
|
||
actions: (
|
||
<button
|
||
type="button"
|
||
onClick={onOpenLogs}
|
||
data-tooltip="查看扩展日志"
|
||
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 → 最后再加一个 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: <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 && 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 <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 在两种状态下
|
||
// 给出不同的提示文案。
|
||
// 有会话 id 就在最后加 Eraser 清除本机记录。本机没有会话文件时只读且
|
||
// 不直接给 ▶:重新实施会先删远程 feature 分支,会把别的机器上的实施
|
||
// 进度一并抹掉;确要重来得先用 Eraser 清掉 id,▶ 再按无会话的规则出现。
|
||
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 (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 <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'),
|
||
}]
|
||
: []),
|
||
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: <Play className="size-3.5" />,
|
||
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: <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 (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 <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)。有会话 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: <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 (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 <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 ? '开' : '关'})`,
|
||
},
|
||
...(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 的 <!-- 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, 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<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,
|
||
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 (
|
||
<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"
|
||
data-tooltip="此工单来自 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)]"
|
||
data-tooltip={lockTitle}
|
||
>
|
||
等待 #
|
||
{prerequisiteNumber}
|
||
{' '}
|
||
完成
|
||
</span>
|
||
)
|
||
: null}
|
||
</h3>
|
||
{readOnly && ownerLogin
|
||
? (
|
||
<span
|
||
className="inline-flex size-6 shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)]"
|
||
data-tooltip={`${ownerLogin} 的工单,只能查看`}
|
||
aria-label={`${ownerLogin} 的工单,只能查看`}
|
||
>
|
||
{avatars?.[ownerLogin]
|
||
? <img src={avatars[ownerLogin]} alt="" className="size-4 rounded-full" />
|
||
: <span className="text-[10px]">{ownerLogin.slice(0, 1)}</span>}
|
||
</span>
|
||
)
|
||
: null}
|
||
<button
|
||
type="button"
|
||
data-tooltip={`在 ${trackerName(issue)} 打开`}
|
||
aria-label={`在 ${trackerName(issue)} 打开`}
|
||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||
>
|
||
<ExternalLink className="size-3.5" />
|
||
</button>
|
||
{canAskHandoff(issue, me) && (
|
||
<button
|
||
type="button"
|
||
data-tooltip="申请移交"
|
||
aria-label="申请移交"
|
||
onClick={() => onAskHandoff(issue.number)}
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||
>
|
||
<UserPlus className="size-3.5" />
|
||
</button>
|
||
)}
|
||
{canCancelHandoffRequest(issue, me) && (
|
||
<button
|
||
type="button"
|
||
data-tooltip={issue.handoffRequestError && issue.handoffRequestError !== 'rejected' ? `取消申请:${issue.handoffRequestError}` : '取消申请'}
|
||
aria-label="取消申请"
|
||
onClick={() => onCancelHandoffRequest(issue.number)}
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||
>
|
||
<Undo2 className="size-3.5" />
|
||
</button>
|
||
)}
|
||
{canAnswerHandoffRequest(issue, me) && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
disabled={isHandoffRunning(issue.number)}
|
||
data-tooltip={issue.handoffRequestError && issue.handoffRequestError !== 'rejected'
|
||
? `同意 ${issue.handoffRequestFrom} 的申请。上次没交出来:${issue.handoffRequestError}`
|
||
: `同意 ${issue.handoffRequestFrom} 的申请`}
|
||
aria-label="同意移交申请"
|
||
onClick={() => onAcceptHandoffRequest(issue.number)}
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-green-500/60 bg-green-500/10 p-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
||
>
|
||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-tooltip={`拒绝 ${issue.handoffRequestFrom} 的申请`}
|
||
aria-label="拒绝移交申请"
|
||
onClick={() => onRejectHandoffRequest(issue.number)}
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||
>
|
||
<X className="size-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
{/* Close/delete write to the forge by number — hide for youtrack cards to
|
||
avoid acting on a forge issue of the same number. */}
|
||
{issue.source !== 'youtrack' && !readOnly && (
|
||
<>
|
||
{issue.handoffBundleKey && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||
<button
|
||
type="button"
|
||
data-tooltip={`接管 ${issue.handoffFrom ?? '?'} 的移交`}
|
||
disabled={isHandoffRunning(issue.number)}
|
||
onClick={() => onAcceptHandoff(issue.number)}
|
||
aria-label="接管"
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-green-500/60 bg-green-500/10 p-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>
|
||
)}
|
||
{!issue.handoffBundleKey && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
|
||
<button
|
||
type="button"
|
||
data-tooltip="移交给同事"
|
||
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>
|
||
)}
|
||
{issue.handoffBundleKey && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
|
||
<span
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs opacity-70"
|
||
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}
|
||
aria-label="待接管"
|
||
>
|
||
<Download className="size-3.5" />
|
||
</span>
|
||
)}
|
||
{(issue.column === 'in-progress' || issue.column === 'review') && (
|
||
<button
|
||
type="button"
|
||
data-tooltip="重置为待办(停会话、删 worktree 和未合并 PR)"
|
||
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>
|
||
)}
|
||
<button
|
||
type="button"
|
||
data-tooltip={`关闭工单(可在 ${trackerName(issue)} 重开)`}
|
||
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>
|
||
<button
|
||
type="button"
|
||
data-tooltip="删除工单(不可撤销)"
|
||
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>
|
||
</>
|
||
)}
|
||
</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)}
|
||
data-tooltip={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 === '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)
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|