From 6342f39de05d9ff43e8c17313300e70b33dac0e6 Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 28 Sep 2026 00:17:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E5=88=97=E5=8F=AF=E4=BB=A5=E7=94=B3=E8=AF=B7=E5=90=8C=E4=BA=8B?= =?UTF-8?q?=E7=A7=BB=E4=BA=A4=E5=B7=A5=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/issue-handoff/spec.md | 2 + tui/internal/issue/statejson.go | 2 + vscode/cli/cmd/spx/main.go | 2 + vscode/cli/internal/state/schema.json | 8 ++ vscode/schemas/state-json.schema.json | 8 ++ vscode/src/gitea/issueLoader.ts | 16 +++ vscode/src/gitea/stateJson.ts | 2 +- vscode/src/gitea/types.ts | 4 + vscode/src/panel/KanbanPanel.ts | 16 +++ vscode/src/panel/handlers/handoffFlow.ts | 134 ++++++++++++++---- .../src/panel/handlers/handoffRequest.test.ts | 46 ++++++ vscode/src/panel/handlers/handoffRequest.ts | 58 ++++++++ vscode/src/panel/handlers/issues.ts | 16 +++ vscode/src/panel/messages.ts | 6 +- vscode/src/panel/readOnlyGuard.ts | 2 + vscode/src/youtrack/stateComment.ts | 2 +- vscode/webview-ui/src/App.tsx | 8 ++ .../webview-ui/src/components/IssueCard.tsx | 79 ++++++++++- .../webview-ui/src/components/KanbanBoard.tsx | 13 ++ vscode/webview-ui/src/hooks/useIssues.ts | 47 +++++- vscode/webview-ui/src/lib/messages.ts | 6 +- vscode/webview-ui/src/types.ts | 4 + 22 files changed, 450 insertions(+), 31 deletions(-) create mode 100644 vscode/src/panel/handlers/handoffRequest.test.ts create mode 100644 vscode/src/panel/handlers/handoffRequest.ts diff --git a/docs/superpowers/specs/issue-handoff/spec.md b/docs/superpowers/specs/issue-handoff/spec.md index a841123..da21337 100644 --- a/docs/superpowers/specs/issue-handoff/spec.md +++ b/docs/superpowers/specs/issue-handoff/spec.md @@ -90,6 +90,8 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有) 进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、检查会话大小、压缩检查、关闭终端、收集会话、打包、上传、写入状态、指派、清理本机)。移交结束前不自动消失。 +审查列里,负责人不是自己、也没有待接管包的 Gitea 工单,卡片上可以「申请移交」。同一张卡同时只有一条申请。同事收到提示,卡片上可以同意或拒绝。同意走上面的移交,对象就是申请人;申请人仍自己点接管。拒绝后申请消失,申请人看到被拒绝。申请人可以在回应前取消。同意后若移交失败,申请留着,两边都看到原因,修好可以再点同意。工单离开审查列时清掉未处理的申请。共享字段 `handoffRequestFrom` / `handoffRequestError`(拒绝时 error 为 `rejected`)。 + 回滚:大小检查失败 → 什么都不改;打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删附件;指派失败 → 清回两个共享字段、删附件。结束 `toast/dismiss`。 ## 接管方:接管 diff --git a/tui/internal/issue/statejson.go b/tui/internal/issue/statejson.go index de73ec1..e9f8518 100644 --- a/tui/internal/issue/statejson.go +++ b/tui/internal/issue/statejson.go @@ -29,6 +29,8 @@ var knownStateFields = map[string]bool{ "implementStatus": true, "color": true, "autoReview": true, + "handoffRequestFrom": true, + "handoffRequestError": true, } // ExtractStateJSON scans comments tail-first and returns the first comment diff --git a/vscode/cli/cmd/spx/main.go b/vscode/cli/cmd/spx/main.go index a26dff5..1a56872 100644 --- a/vscode/cli/cmd/spx/main.go +++ b/vscode/cli/cmd/spx/main.go @@ -427,6 +427,8 @@ var knownStateFields = map[string]struct{}{ "autoReview": {}, "handoffAttachmentId": {}, "handoffFrom": {}, + "handoffRequestFrom": {}, + "handoffRequestError": {}, } // parseStateComment returns the parsed map when body is a JSON object carrying diff --git a/vscode/cli/internal/state/schema.json b/vscode/cli/internal/state/schema.json index c06b28b..2ba504c 100644 --- a/vscode/cli/internal/state/schema.json +++ b/vscode/cli/internal/state/schema.json @@ -125,6 +125,14 @@ "handoffFrom": { "type": "string", "description": "发起移交的 Gitea login;清空时写空串。" + }, + "handoffRequestFrom": { + "type": "string", + "description": "谁在申请把审查中的工单移交给自己;清空时写空串。" + }, + "handoffRequestError": { + "type": "string", + "description": "最近一次同意移交失败的原因;对方拒绝时写 rejected;清空时写空串。" } } } diff --git a/vscode/schemas/state-json.schema.json b/vscode/schemas/state-json.schema.json index c06b28b..2ba504c 100644 --- a/vscode/schemas/state-json.schema.json +++ b/vscode/schemas/state-json.schema.json @@ -125,6 +125,14 @@ "handoffFrom": { "type": "string", "description": "发起移交的 Gitea login;清空时写空串。" + }, + "handoffRequestFrom": { + "type": "string", + "description": "谁在申请把审查中的工单移交给自己;清空时写空串。" + }, + "handoffRequestError": { + "type": "string", + "description": "最近一次同意移交失败的原因;对方拒绝时写 rejected;清空时写空串。" } } } diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 45a1a89..4e63ed7 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -102,6 +102,8 @@ function parseColumnFromComments(comments: GiteaComment[]): { autoReview?: boolean handoffAttachmentId?: string handoffFrom?: string + handoffRequestFrom?: string + handoffRequestError?: string } { if (comments.length === 0) return { column: null } @@ -151,6 +153,8 @@ function parseColumnFromComments(comments: GiteaComment[]): { autoReview?: unknown handoffAttachmentId?: unknown handoffFrom?: unknown + handoffRequestFrom?: unknown + handoffRequestError?: unknown } if (isIssueColumn(obj.column)) { const sessionId = typeof obj.sessionId === 'string' && obj.sessionId.length > 0 @@ -212,6 +216,12 @@ function parseColumnFromComments(comments: GiteaComment[]): { const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0 ? obj.handoffFrom : undefined + const handoffRequestFrom = typeof obj.handoffRequestFrom === 'string' && obj.handoffRequestFrom.length > 0 + ? obj.handoffRequestFrom + : undefined + const handoffRequestError = typeof obj.handoffRequestError === 'string' && obj.handoffRequestError.length > 0 + ? obj.handoffRequestError + : undefined return { column: obj.column, sessionId, @@ -237,6 +247,8 @@ function parseColumnFromComments(comments: GiteaComment[]): { autoReview, handoffAttachmentId, handoffFrom, + handoffRequestFrom, + handoffRequestError, } } } @@ -381,6 +393,8 @@ async function buildIssue(opts: { autoReview, handoffAttachmentId, handoffFrom, + handoffRequestFrom, + handoffRequestError, } = parseColumnFromComments(comments) // 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。 const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged @@ -449,6 +463,8 @@ async function buildIssue(opts: { ...(autoReview !== undefined ? { autoReview } : {}), ...(handoffAttachmentId ? { handoffAttachmentId } : {}), ...(handoffFrom ? { handoffFrom } : {}), + ...(handoffRequestFrom ? { handoffRequestFrom } : {}), + ...(handoffRequestError ? { handoffRequestError } : {}), assignees: (issue.assignees ?? []).map(a => a.login), ...(issue.user?.login ? { poster: issue.user.login } : {}), htmlUrl: issue.html_url, diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index 33f8e10..cc25a47 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api' * 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前 * 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。 */ -const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const +const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const /** * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。 diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index f08e049..d0895ec 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -139,6 +139,10 @@ export interface Issue { handoffAttachmentId?: string /** 发起移交的 Gitea login。 */ handoffFrom?: string + /** 谁在申请把这个审查工单移交给自己。 */ + handoffRequestFrom?: string + /** 同意移交失败的原因。对方拒绝时为 rejected。 */ + handoffRequestError?: string /** Whether the brainstorm terminal tab is currently alive in this VS Code window. */ brainstormTabOpen?: boolean /** Whether the implementation terminal tab is currently alive in this VS Code window. */ diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index fbaa31a..71611e3 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -525,6 +525,22 @@ export class KanbanWebviewPanel { void handoffFlow.handleHandoffAccept(this, msg.issueNumber) return } + if (msg.type === 'handoff/request') { + void handoffFlow.handleHandoffRequest(this, msg.issueNumber) + return + } + if (msg.type === 'handoff/request-cancel') { + void handoffFlow.handleHandoffRequestCancel(this, msg.issueNumber) + return + } + if (msg.type === 'handoff/request-accept') { + void handoffFlow.handleHandoffRequestAccept(this, msg.issueNumber) + return + } + if (msg.type === 'handoff/request-reject') { + void handoffFlow.handleHandoffRequestReject(this, msg.issueNumber) + return + } if (msg.type === 'session-handoff/users') { void sessionHandoff.handleSessionHandoffUsers(this) return diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 98a15f6..eeb3ec2 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -43,6 +43,7 @@ import { handoffStartedStateExtra, handoffStartedUiPatch, } from './handoff' +import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest' export interface RepoCtx { workspaceRoot: string @@ -134,14 +135,16 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: * 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住 * 未预期异常并保证 `handoff/done` 一定发出去。 */ -export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise { +export type HandoffStartResult = { ok: true } | { ok: false, message: string } + +export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise { const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) // spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。 let spinner: string | undefined try { const ctx = await resolveRepoCtx(panel) if (!ctx) - return + return { ok: false, message: '无法开始移交' } const { workspaceRoot, host, owner, repo, token, me } = ctx let state: Record @@ -149,8 +152,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: state = await panel.readIssueState(issueNumber) } catch (err) { - toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) - return + const message = `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}` + toast(panel, 'error', message) + return { ok: false, message } } // 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到 @@ -158,11 +162,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) if (!issue) { toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) - return + return { ok: false, message: `工单 #${issueNumber} 不存在或已删除` } } if (issue.state === 'closed') { toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`) - return + return { ok: false, message: `#${issueNumber} 已关闭,不能移交` } } // 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到 @@ -172,8 +176,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' const assignees = (issue.assignees ?? []).map(a => a.login) if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) { - toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`) - return + const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交` + toast(panel, 'error', message) + return { ok: false, message } } const branch = str(state.branch) @@ -193,10 +198,11 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: } const say = (step: string): string => toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}:${step}`, { id: spinner, spinner: true, dismissOnTimer: 0 }) - const fail = (message: string): void => { + const fail = (message: string): HandoffStartResult => { if (spinner) dismiss(panel, spinner) toast(panel, 'error', message) + return { ok: false, message } } // ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时 // 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。 @@ -206,21 +212,18 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined if (worktreeAbs && fs.existsSync(worktreeAbs)) { if (!branch) { - fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`) - return + return fail(`无法移交 #${issueNumber}:state 缺少 branch,无法校验是否已 push`) } const status = await git(worktreeAbs, ['status', '--porcelain']) const fetched = await git(worktreeAbs, ['fetch', 'origin', branch]) if (!status.ok || !fetched.ok) { - fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`) - return + return fail(`检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`) } const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD']) const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`]) const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout }) if (!check.ok) { - fail(`无法移交 #${issueNumber}:${check.reason}`) - return + return fail(`无法移交 #${issueNumber}:${check.reason}`) } } else if (branch) { @@ -231,8 +234,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`]) const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`]) if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) { - fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`) - return + return fail(`无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`) } } } @@ -254,12 +256,10 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: } catch (err) { if (err instanceof HandoffSourcesTooLargeError) { - fail(`#${issueNumber} ${err.message}`) - return + return fail(`#${issueNumber} ${err.message}`) } const message = err instanceof Error ? err.message : String(err) - fail(`移交失败(检查会话大小):${message}`) - return + return fail(`移交失败(检查会话大小):${message}`) } // ② 关终端,等 jsonl 刷盘 @@ -318,7 +318,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message }) dismiss(panel, spinner) toast(panel, 'error', `移交失败(${prefix}):${message}`) - return + return { ok: false, message: `移交失败(${prefix}):${message}` } } finally { // rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 @@ -338,7 +338,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) dismiss(panel, spinner) toast(panel, 'error', `移交失败(写状态):${message}`) - return + return { ok: false, message: `移交失败(写状态):${message}` } } say('指派') try { @@ -351,7 +351,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) dismiss(panel, spinner) toast(panel, 'error', `移交失败(指派 ${to}):${message}`) - return + return { ok: false, message: `移交失败(指派 ${to}):${message}` } } // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警—— @@ -396,6 +396,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: dismiss(panel, spinner) panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) }) toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`) + await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }).catch(() => {}) + panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } }) + return { ok: true } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -403,6 +406,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: toast(panel, 'error', `移交失败:${message}`) if (spinner) dismiss(panel, spinner) + return { ok: false, message: `移交失败:${message}` } } finally { finish() @@ -414,6 +418,88 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: * 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 → * 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。 */ +async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffAttachmentId?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> { + const ctx = await resolveRepoCtx(panel) + if (!ctx) + return + let state: Record + try { + state = await panel.readIssueState(issueNumber) + } + catch (err) { + toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`) + return + } + const issue = await getIssue({ host: ctx.host, token: ctx.token, owner: ctx.owner, repo: ctx.repo, index: issueNumber }) + if (!issue) { + toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) + return + } + const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] + const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' + return { + me: ctx.me, + facts: { + source: 'gitea', + column, + ...(str(state.handoffAttachmentId) ? { handoffAttachmentId: str(state.handoffAttachmentId) } : {}), + ...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}), + assignees: (issue.assignees ?? []).map(a => a.login), + }, + } +} + +export async function handleHandoffRequest(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const loaded = await requestFacts(panel, issueNumber) + if (!loaded) + return + if (!canAskHandoff(loaded.facts, loaded.me)) { + toast(panel, 'error', `#${issueNumber} 现在不能申请移交`) + return + } + await panel.mergeIssueState(issueNumber, { handoffRequestFrom: loaded.me, handoffRequestError: '' }) + panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: loaded.me, handoffRequestError: null } }) +} + +export async function handleHandoffRequestCancel(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const loaded = await requestFacts(panel, issueNumber) + if (!loaded) + return + if (!canCancelHandoffRequest(loaded.facts, loaded.me)) { + toast(panel, 'error', `#${issueNumber} 没有你的移交申请`) + return + } + await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }) + panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } }) +} + +export async function handleHandoffRequestReject(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const loaded = await requestFacts(panel, issueNumber) + if (!loaded) + return + if (!canAnswerHandoffRequest(loaded.facts, loaded.me)) { + toast(panel, 'error', `#${issueNumber} 不能拒绝这个申请`) + return + } + await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: 'rejected' }) + panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: 'rejected' } }) +} + +export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise { + const loaded = await requestFacts(panel, issueNumber) + if (!loaded) + return + if (!canAnswerHandoffRequest(loaded.facts, loaded.me) || !loaded.facts.handoffRequestFrom) { + toast(panel, 'error', `#${issueNumber} 没有待处理的移交申请`) + return + } + const result = await handleHandoffStart(panel, issueNumber, loaded.facts.handoffRequestFrom) + if (result.ok) + return + await panel.mergeIssueState(issueNumber, { handoffRequestError: result.message }).catch(() => {}) + panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestError: result.message } }) +} + export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise { // finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。 const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) diff --git a/vscode/src/panel/handlers/handoffRequest.test.ts b/vscode/src/panel/handlers/handoffRequest.test.ts new file mode 100644 index 0000000..12f42ea --- /dev/null +++ b/vscode/src/panel/handlers/handoffRequest.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest, handoffRequestNotices } from './handoffRequest' + +const review = { + source: 'gitea' as const, + column: 'review' as const, + assignees: ['chw'], +} + +describe('移交申请', () => { + it('审查列里同事的工单可以申请', () => { + expect(canAskHandoff(review, 'cruldra')).toBe(true) + }) + + it('自己的、没人负责的、不在审查列的、YouTrack 的、已在移交中的,不能申请', () => { + expect(canAskHandoff(review, 'chw')).toBe(false) + expect(canAskHandoff({ ...review, assignees: [] }, 'cruldra')).toBe(false) + expect(canAskHandoff({ ...review, column: 'in-progress' }, 'cruldra')).toBe(false) + expect(canAskHandoff({ ...review, source: 'youtrack' }, 'cruldra')).toBe(false) + expect(canAskHandoff({ ...review, handoffAttachmentId: '9' }, 'cruldra')).toBe(false) + expect(canAskHandoff(review, undefined)).toBe(false) + }) + + it('已经有一条申请时,别人不能再申请,申请人可以取消', () => { + const pending = { ...review, handoffRequestFrom: 'cruldra' } + expect(canAskHandoff(pending, 'other')).toBe(false) + expect(canCancelHandoffRequest(pending, 'cruldra')).toBe(true) + expect(canCancelHandoffRequest(pending, 'other')).toBe(false) + }) + + it('负责人看到申请,申请人看到拒绝和失败原因', () => { + const pending = { ...review, number: 435, handoffRequestFrom: 'cruldra' } + expect(handoffRequestNotices({ ...review, number: 435 }, pending, 'chw')).toEqual(['cruldra 想要 #435']) + expect(handoffRequestNotices(pending, { ...pending, handoffRequestFrom: undefined, handoffRequestError: 'rejected' }, 'cruldra')) + .toEqual(['#435 的移交申请被拒绝了']) + expect(handoffRequestNotices(pending, { ...pending, handoffRequestError: '会话太大' }, 'cruldra')) + .toEqual(['#435 没交出来:会话太大']) + }) + + it('只有负责人能同意或拒绝', () => { + const pending = { ...review, handoffRequestFrom: 'cruldra' } + expect(canAnswerHandoffRequest(pending, 'chw')).toBe(true) + expect(canAnswerHandoffRequest(pending, 'cruldra')).toBe(false) + expect(canAnswerHandoffRequest({ ...pending, column: 'todo' }, 'chw')).toBe(false) + }) +}) diff --git a/vscode/src/panel/handlers/handoffRequest.ts b/vscode/src/panel/handlers/handoffRequest.ts new file mode 100644 index 0000000..f2f4e2e --- /dev/null +++ b/vscode/src/panel/handlers/handoffRequest.ts @@ -0,0 +1,58 @@ +export interface HandoffRequestFacts { + source?: 'gitea' | 'youtrack' + column: 'todo' | 'in-progress' | 'review' | 'done' + handoffAttachmentId?: string + handoffRequestFrom?: string + handoffRequestError?: string + assignees?: string[] +} + +function openReview(issue: HandoffRequestFacts): boolean { + return issue.source !== 'youtrack' && issue.column === 'review' && !issue.handoffAttachmentId +} + +/** 审查列里同事负责的工单,且当前没有别的申请。 */ +export function canAskHandoff(issue: HandoffRequestFacts, me: string | undefined): boolean { + if (!me || !openReview(issue)) + return false + const assignees = issue.assignees ?? [] + if (assignees.length === 0 || assignees.includes(me)) + return false + return !issue.handoffRequestFrom +} + +/** 申请人在对方回应前可以取消。 */ +export function canCancelHandoffRequest(issue: HandoffRequestFacts, me: string | undefined): boolean { + return !!me && openReview(issue) && issue.handoffRequestFrom === me +} + +/** 负责人同意或拒绝。同意会走现有移交。 */ +export function handoffRequestNotices(before: HandoffRequestFacts & { number: number }, next: HandoffRequestFacts & { number: number }, me: string): string[] { + const notices: string[] = [] + if ( + next.handoffRequestFrom + && next.handoffRequestFrom !== before.handoffRequestFrom + && next.handoffRequestFrom !== me + && (next.assignees ?? []).includes(me) + ) { + notices.push(`${next.handoffRequestFrom} 想要 #${next.number}`) + } + if (before.handoffRequestFrom === me && next.handoffRequestError === 'rejected' && before.handoffRequestError !== 'rejected') + notices.push(`#${next.number} 的移交申请被拒绝了`) + if ( + next.handoffRequestFrom === me + && next.handoffRequestError + && next.handoffRequestError !== 'rejected' + && next.handoffRequestError !== before.handoffRequestError + ) { + notices.push(`#${next.number} 没交出来:${next.handoffRequestError}`) + } + return notices +} + +export function canAnswerHandoffRequest(issue: HandoffRequestFacts, me: string | undefined): boolean { + return !!me + && openReview(issue) + && !!issue.handoffRequestFrom + && (issue.assignees ?? []).includes(me) +} diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 5de9daf..26f26b8 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -45,6 +45,22 @@ import * as sessions from './sessions' import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset' export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise { + if (toColumn !== 'review') { + try { + const requestState = await panel.readIssueState(issueNumber) + if (typeof requestState.handoffRequestFrom === 'string' && requestState.handoffRequestFrom.length > 0) { + await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }) + panel.postMessage({ + type: 'issue/patch', + issueNumber, + patch: { handoffRequestFrom: null, handoffRequestError: null }, + }) + } + } + catch { + // 清申请失败不拦换列 + } + } if (toColumn === 'in-progress') { await handleDropToInProgress(panel, issueNumber) return diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index ef615f6..ea4acb2 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -52,7 +52,7 @@ export type ExtensionToWebview | { type: 'issues/loaded' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } } /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } @@ -214,6 +214,10 @@ export type WebviewToExtension | { type: 'handoff/users', issueNumber: number } | { type: 'handoff/start', issueNumber: number, to: string } | { type: 'handoff/accept', issueNumber: number } + | { type: 'handoff/request', issueNumber: number } + | { type: 'handoff/request-cancel', issueNumber: number } + | { type: 'handoff/request-accept', issueNumber: number } + | { type: 'handoff/request-reject', issueNumber: number } | { type: 'session-handoff/users' } | { type: 'session-handoff/start', sessionId: string, to: string } | { type: 'session-handoff/accept', attachmentId: number } diff --git a/vscode/src/panel/readOnlyGuard.ts b/vscode/src/panel/readOnlyGuard.ts index c872f84..6add462 100644 --- a/vscode/src/panel/readOnlyGuard.ts +++ b/vscode/src/panel/readOnlyGuard.ts @@ -36,6 +36,8 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet = new Set([ 'dependency/clear', 'handoff/users', 'handoff/start', + 'handoff/request-accept', + 'handoff/request-reject', // 分支与 worktree 'git/merge-preview', 'worktree/delete', diff --git a/vscode/src/youtrack/stateComment.ts b/vscode/src/youtrack/stateComment.ts index 102bdec..6111088 100644 --- a/vscode/src/youtrack/stateComment.ts +++ b/vscode/src/youtrack/stateComment.ts @@ -20,7 +20,7 @@ import { addComment, listComments } from './api' export const STATE_MARKER = '' /** Same field set as the Gitea state blob — kept independent to avoid coupling. */ -const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const +const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const /** * Parse a comment body into a state object, or null if it isn't a state diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 21a592c..d1f3734 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -94,6 +94,10 @@ export function App() { requestHandoffUsers, startHandoff, acceptHandoff, + askHandoff, + cancelHandoffRequest, + acceptHandoffRequest, + rejectHandoffRequest, isHandoffRunning, } = useIssues() const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles() @@ -389,6 +393,10 @@ export function App() { onDependencyClear={clearDependency} me={me} avatars={avatars} + onAskHandoff={askHandoff} + onCancelHandoffRequest={cancelHandoffRequest} + onAcceptHandoffRequest={acceptHandoffRequest} + onRejectHandoffRequest={rejectHandoffRequest} />
diff --git a/vscode/webview-ui/src/components/IssueCard.tsx b/vscode/webview-ui/src/components/IssueCard.tsx index 10b2e1c..f522639 100644 --- a/vscode/webview-ui/src/components/IssueCard.tsx +++ b/vscode/webview-ui/src/components/IssueCard.tsx @@ -1,10 +1,11 @@ -import type { Ref } from 'react' +import type { ReactNode, Ref } from 'react' import type { Issue } from '../types' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Lock } from 'lucide-react' import { forwardRef } from 'react' import { cn } from '@/lib/utils' +import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from '../../../src/panel/handlers/handoffRequest' import { Card } from './ui/card' interface IssueCardProps { @@ -26,6 +27,11 @@ interface IssueCardProps { ownerLogin?: string /** 归属人头像 data URI;缺失时回退首字母圆点。 */ ownerAvatar?: string + me?: string + onAskHandoff?: (issueNumber: number) => void + onCancelHandoffRequest?: (issueNumber: number) => void + onAcceptHandoffRequest?: (issueNumber: number) => void + onRejectHandoffRequest?: (issueNumber: number) => void } /** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */ @@ -51,8 +57,69 @@ function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) { ) } +function HandoffRequestActions({ + issue, + me, + onAsk, + onCancel, + onAccept, + onReject, +}: { + issue: Issue + me?: string + onAsk?: (issueNumber: number) => void + onCancel?: (issueNumber: number) => void + onAccept?: (issueNumber: number) => void + onReject?: (issueNumber: number) => void +}) { + const button = 'rounded border border-[var(--vscode-contrastBorder)] px-1.5 py-0.5 text-[10px] hover:bg-[var(--vscode-toolbar-hoverBackground)]' + let body: ReactNode = null + if (canAnswerHandoffRequest(issue, me)) { + body = ( + <> + + {issue.handoffRequestFrom} + {' '} + 想要 + + {issue.handoffRequestError && issue.handoffRequestError !== 'rejected' + ? {issue.handoffRequestError} + : null} + + + + ) + } + else if (canCancelHandoffRequest(issue, me)) { + body = ( + <> + + {issue.handoffRequestError && issue.handoffRequestError !== 'rejected' ? issue.handoffRequestError : '已申请'} + + + + ) + } + else if (canAskHandoff(issue, me)) { + body = + } + else if (issue.column === 'review' && issue.handoffRequestFrom && issue.source !== 'youtrack' && !issue.handoffAttachmentId) { + body = 申请中 + } + if (!body) + return null + return ( +
event.stopPropagation()} + > + {body} +
+ ) +} + export const IssueCard = forwardRef( - ({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar }, ref) => { + ({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar, me, onAskHandoff, onCancelHandoffRequest, onAcceptHandoffRequest, onRejectHandoffRequest }, ref) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly }) @@ -111,6 +178,14 @@ export const IssueCard = forwardRef( ) : null} +
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} diff --git a/vscode/webview-ui/src/components/KanbanBoard.tsx b/vscode/webview-ui/src/components/KanbanBoard.tsx index d0ef0f5..a50c356 100644 --- a/vscode/webview-ui/src/components/KanbanBoard.tsx +++ b/vscode/webview-ui/src/components/KanbanBoard.tsx @@ -45,6 +45,10 @@ interface KanbanBoardProps { me?: string /** Gitea login → 头像 data URI。 */ avatars?: Record + onAskHandoff?: (issueNumber: number) => void + onCancelHandoffRequest?: (issueNumber: number) => void + onAcceptHandoffRequest?: (issueNumber: number) => void + onRejectHandoffRequest?: (issueNumber: number) => void } const kanbanCollisionDetection: CollisionDetection = (args) => { @@ -119,6 +123,10 @@ export function KanbanBoard({ onDependencyClear, me, avatars, + onAskHandoff, + onCancelHandoffRequest, + onAcceptHandoffRequest, + onRejectHandoffRequest, }: KanbanBoardProps) { const [activeId, setActiveId] = useState(null) @@ -281,6 +289,11 @@ export function KanbanBoard({ readOnly={readOnly} ownerLogin={ownerLogin} ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined} + me={me} + onAskHandoff={onAskHandoff} + onCancelHandoffRequest={onCancelHandoffRequest} + onAcceptHandoffRequest={onAcceptHandoffRequest} + onRejectHandoffRequest={onRejectHandoffRequest} /> ) })} diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 0daa369..fd2287a 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -20,6 +20,7 @@ import type { ToastItem } from '../components/ToastStack' import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages' import type { Issue, IssueColumn } from '../types' import { useCallback, useEffect, useRef, useState } from 'react' +import { handoffRequestNotices } from '../../../src/panel/handlers/handoffRequest' import { compareIssuesInColumn } from '../lib/issueSort' import { onMessage, postMessage } from '../lib/vscode' @@ -320,6 +321,11 @@ export interface UseIssuesResult { startHandoff: (issueNumber: number, to: string) => void /** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */ acceptHandoff: (issueNumber: number) => void + /** 审查列里向同事申请把工单移交给自己。 */ + askHandoff: (issueNumber: number) => void + cancelHandoffRequest: (issueNumber: number) => void + acceptHandoffRequest: (issueNumber: number) => void + rejectHandoffRequest: (issueNumber: number) => void /** 是否有移交/接管流程正在该工单上跑,直到扩展端回 `handoff/done` 才清除。 */ isHandoffRunning: (issueNumber: number) => boolean } @@ -632,6 +638,18 @@ export function useIssues(): UseIssuesResult { postMessage({ type: 'handoff/start', issueNumber, to }) }, [markHandoffRunning]) + const askHandoff = useCallback((issueNumber: number): void => { + postMessage({ type: 'handoff/request', issueNumber }) + }, []) + const cancelHandoffRequest = useCallback((issueNumber: number): void => { + postMessage({ type: 'handoff/request-cancel', issueNumber }) + }, []) + const acceptHandoffRequest = useCallback((issueNumber: number): void => { + postMessage({ type: 'handoff/request-accept', issueNumber }) + }, []) + const rejectHandoffRequest = useCallback((issueNumber: number): void => { + postMessage({ type: 'handoff/request-reject', issueNumber }) + }, []) const acceptHandoff = useCallback((issueNumber: number): void => { if (handoffRunningRef.current.has(issueNumber)) return @@ -1042,5 +1060,32 @@ export function useIssues(): UseIssuesResult { return cleanup }, [clearPrDiffSummaryRunning, markHandoffRunning]) - return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } + const seenIssues = useRef(null) + useEffect(() => { + if (state.status !== 'ready') + return + const prev = seenIssues.current + seenIssues.current = state.issues + if (!prev || !me) + return + const messages: string[] = [] + for (const next of state.issues) { + const before = prev.find(issue => issue.number === next.number) + if (before) + messages.push(...handoffRequestNotices(before, next, me)) + } + if (messages.length === 0) + return + setToasts(current => [ + ...current, + ...messages.map(message => ({ + id: `handoff-request-${message}`, + level: 'info' as const, + message, + dismissOnTimer: 8000, + })), + ]) + }, [state, me]) + + return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest } } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 314966d..d99e138 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -83,7 +83,7 @@ export type ExtensionToWebview | { type: 'issues/loaded' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } } /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } @@ -245,6 +245,10 @@ export type WebviewToExtension | { type: 'handoff/users', issueNumber: number } | { type: 'handoff/start', issueNumber: number, to: string } | { type: 'handoff/accept', issueNumber: number } + | { type: 'handoff/request', issueNumber: number } + | { type: 'handoff/request-cancel', issueNumber: number } + | { type: 'handoff/request-accept', issueNumber: number } + | { type: 'handoff/request-reject', issueNumber: number } | { type: 'session-handoff/users' } | { type: 'session-handoff/start', sessionId: string, to: string } | { type: 'session-handoff/accept', attachmentId: number } diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index 84167dd..168a1c5 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -103,6 +103,10 @@ export interface Issue { handoffAttachmentId?: string /** 发起移交的 Gitea login。 */ handoffFrom?: string + /** 谁在申请把这个审查工单移交给自己。 */ + handoffRequestFrom?: string + /** 同意移交失败的原因。对方拒绝时为 rejected。 */ + handoffRequestError?: string /** * 各类会话 tab 是否当前在编辑器分组里打开(由 extension 通过 issue/patch 同步)。 * 仅用于详情面板控制是否在会话 id 行右侧显示 "关闭 tab" 按钮,不持久化到 state JSON。