From bf4661e3642613902237cc95922e02179507794e Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 24 Sep 2026 19:22:29 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E6=9C=BA=E5=99=A8=E4=B8=8A=E5=BC=80=E7=9A=84=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=9C=A8=E6=9C=AC=E6=9C=BA=E6=B2=A1=E6=9C=89=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=96=87=E4=BB=B6=E6=97=B6=EF=BC=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=20id=20=E8=A1=8C=E7=BD=AE=E7=81=B0=E4=B8=8D=E5=8F=AF=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=EF=BC=8C=E4=B8=8D=E5=86=8D=20resume=20=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/gitea/types.ts | 9 +- vscode/src/panel/KanbanPanel.ts | 8 +- vscode/src/panel/brainstormContinue.ts | 7 +- vscode/src/panel/handlers/handoff.test.ts | 21 +-- vscode/src/panel/handlers/handoff.ts | 5 +- vscode/src/panel/handlers/handoffFlow.ts | 12 +- vscode/src/panel/handlers/sessions.ts | 70 ++++++-- vscode/src/panel/messages.ts | 2 +- vscode/src/sessions/codexSessions.test.ts | 85 ---------- vscode/src/sessions/codexSessions.ts | 55 ------ vscode/src/sessions/sessionFiles.test.ts | 159 ++++++++++++++++++ vscode/src/sessions/sessionFiles.ts | 121 +++++++++++++ vscode/src/webhook/coordinator.ts | 6 +- vscode/test/panel/brainstormContinue.test.ts | 16 +- vscode/webview-ui/src/App.tsx | 12 +- .../src/components/IssueDetailPanel.tsx | 122 ++++++++++---- vscode/webview-ui/src/lib/messages.ts | 2 +- .../webview-ui/src/lib/missingSession.test.ts | 15 ++ vscode/webview-ui/src/lib/missingSession.ts | 9 + vscode/webview-ui/src/types.ts | 6 +- 20 files changed, 508 insertions(+), 234 deletions(-) delete mode 100644 vscode/src/sessions/codexSessions.test.ts delete mode 100644 vscode/src/sessions/codexSessions.ts create mode 100644 vscode/src/sessions/sessionFiles.test.ts create mode 100644 vscode/src/sessions/sessionFiles.ts create mode 100644 vscode/webview-ui/src/lib/missingSession.test.ts create mode 100644 vscode/webview-ui/src/lib/missingSession.ts diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index 45ebea6..f08e049 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -98,11 +98,12 @@ export interface Issue { */ reviewSessionId?: string /** - * Whether the codex session file (`~/.codex/sessions/YYYY/MM/DD/rollout-...-.jsonl`) - * for `reviewSessionId` exists on this machine; computed on load, never - * persisted. codex resume 只依赖这个文件,与 worktree 无关。 + * 本工单引用的会话 id(sessionId / implementSessionId / testSessionId / + * prototypeSessionId 走 claude,reviewSessionId 走 codex)里本机没有会话 + * 文件的那些;加载时计算,不持久化。会话 id 来自共享状态,别的机器上开的 + * 会话本机 resume 不了。缺省或空 = 全部能 resume。 */ - reviewSessionFileExists?: boolean + missingSessionIds?: string[] /** * Session id of the test conversation (a Claude Code session id). Set the * first time the user manually starts a test session after the issue's PR diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 93b7a63..9235b95 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -25,7 +25,7 @@ import { loadIssues } from '../gitea/issueLoader' import { overlayLocalIssueState } from '../issues/localState' import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter' import { logger } from '../logging/logger' -import { annotateReviewSessionFileExists } from '../sessions/codexSessions' +import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store' import { webhookCoordinator } from '../webhook/coordinator' import { loadYouTrackIssues } from '../youtrack/issueLoader' @@ -1048,10 +1048,10 @@ export class KanbanWebviewPanel { loadIssues({ host, token, owner, repo, workspaceRoot, scope }), this.loadYouTrackSafe(), ]) - // 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex - // 会话文件存在性,再补 live terminal 态。 + // 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有 + // 会话文件的会话 id,再补 live terminal 态。 const issues = this.withLiveTerminalTabState( - await annotateReviewSessionFileExists( + await annotateMissingSessionIds( overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot), ), ) diff --git a/vscode/src/panel/brainstormContinue.ts b/vscode/src/panel/brainstormContinue.ts index 6a4b51f..f0bba7a 100644 --- a/vscode/src/panel/brainstormContinue.ts +++ b/vscode/src/panel/brainstormContinue.ts @@ -1,14 +1,15 @@ /** * 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有 * 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有 - * sessionId 就 `--resume` 并带上提示词;两者都没有才新开会话。 + * sessionId 且本机有它的会话文件就 `--resume` 并带上提示词;否则新开会话 + * ——会话 id 来自共享状态,别的机器上开的会话本机 resume 不了。 */ export type BrainstormContinuePath = 'inject' | 'resume' | 'fresh' -export function pickBrainstormContinuePath(opts: { liveTerminal: boolean, sessionId: string | undefined }): BrainstormContinuePath { +export function pickBrainstormContinuePath(opts: { liveTerminal: boolean, sessionId: string | undefined, sessionFileExists: boolean }): BrainstormContinuePath { if (opts.liveTerminal) return 'inject' - if (opts.sessionId) + if (opts.sessionId && opts.sessionFileExists) return 'resume' return 'fresh' } diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index a5f6976..c56295d 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -153,12 +153,14 @@ describe('payloads', () => { profiles: {}, worktreePath: '/wt', worktreeExists: true, + missingSessionIds: [], })).toEqual({ handoffAttachmentId: null, handoffFrom: null, sessionId: 's', worktreePath: '/wt', worktreeExists: true, + missingSessionIds: [], }) }) it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => { @@ -166,35 +168,30 @@ describe('payloads', () => { sessions: { sessionId: 's' }, profiles: {}, worktreeExists: false, + missingSessionIds: [], })).toEqual({ handoffAttachmentId: null, handoffFrom: null, sessionId: 's', worktreePath: null, worktreeExists: false, + missingSessionIds: [], }) }) - it('接管 UI patch:带 reviewSessionFileExists 时一并下发,UI 不用等下次刷新才能点链接', () => { + it('接管 UI patch:缺失的会话 id 原样下发,UI 立刻知道哪几行不能 resume', () => { expect(handoffAcceptedUiPatch({ - sessions: { reviewSessionId: 'r' }, + sessions: { sessionId: 's', reviewSessionId: 'r' }, profiles: {}, worktreeExists: false, - reviewSessionFileExists: true, + missingSessionIds: ['r'], })).toEqual({ handoffAttachmentId: null, handoffFrom: null, + sessionId: 's', reviewSessionId: 'r', worktreePath: null, worktreeExists: false, - reviewSessionFileExists: true, + missingSessionIds: ['r'], }) }) - it('接管 UI patch:reviewSessionFileExists 未传时不出现在 patch 里', () => { - const patch = handoffAcceptedUiPatch({ - sessions: { sessionId: 's' }, - profiles: {}, - worktreeExists: false, - }) - expect(patch).not.toHaveProperty('reviewSessionFileExists') - }) }) diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 1c14339..35875de 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -110,7 +110,7 @@ export function handoffAcceptedUiPatch(local: { profiles: HandoffProfiles worktreePath?: string worktreeExists: boolean - reviewSessionFileExists?: boolean + missingSessionIds: string[] }): Record { return { handoffAttachmentId: null, @@ -121,6 +121,7 @@ export function handoffAcceptedUiPatch(local: { // 与 worktreeExists: false 对不上。 worktreePath: local.worktreePath ?? null, worktreeExists: local.worktreeExists, - ...(local.reviewSessionFileExists !== undefined ? { reviewSessionFileExists: local.reviewSessionFileExists } : {}), + // 同理空数组也要发:webview 里留着接管前(会话文件还没装进本机)算的缺失列表。 + missingSessionIds: local.missingSessionIds, } } diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index f1e949b..a9512b0 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -28,7 +28,7 @@ import { uploadIssueAttachment, } from '../../gitea/api' import { logger } from '../../logging/logger' -import { defaultCodexSessionsDir } from '../../sessions/codexSessions' +import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles' import { getSettings } from '../../settings/store' import { makeNonce } from '../KanbanPanel' import { cleanupFeatureBranch } from './branchCleanup' @@ -464,11 +464,9 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber }) // ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) - // reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists); - // 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。 - const reviewSessionId = manifest.sessions.reviewSessionId - const reviewSessionFileExists = !!reviewSessionId - && manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase()) + // missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机, + // 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。 + const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions]) const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) try { @@ -482,7 +480,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber panel.postMessage({ type: 'issue/patch', issueNumber, - patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }), + patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, missingSessionIds }), }) toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`) logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) }) diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 6cab981..978a503 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -4,7 +4,6 @@ import type { ResumeKind } from './resumeTarget' import { execFile } from 'node:child_process' import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' -import * as os from 'node:os' import * as path from 'node:path' import { window, workspace } from 'vscode' import { loginForToken } from '../../auth/identity' @@ -20,6 +19,7 @@ import { ensureWorktree, resolveWorktreePath } from '../../git/worktree' import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath' import { updateIssueAssignees } from '../../gitea/api' import { logger } from '../../logging/logger' +import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles' import { getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' import { decideAutoReviewPath } from '../autoReviewDecision' @@ -57,6 +57,26 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } +/** + * 会话 id 来自 Gitea 共享状态,别的机器上开的会话本机没有会话文件,resume + * 必然报「No conversation found」。各 resume 入口在开新终端前查一次,没有就 + * 用这条 toast 拦下(已开着的 tab 直接复用,不受影响)。 + */ +function rejectMissingSessionFile(panel: KanbanWebviewPanel, sessionId: string, issueNumber?: number): void { + logger.add({ + level: 'warn', + source: 'panel', + message: `拒绝 resume #${issueNumber ?? '?'}:本机没有会话 ${sessionId} 的会话文件`, + }) + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'error', + message: `本机没有会话 ${sessionId} 的会话文件(可能在别的机器上创建),无法 resume`, + dismissOnTimer: 8000, + }) +} + export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, sessionKind: ResumeKind, profilePath?: string, relCwd?: string, issueNumber?: number): Promise { // In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s) // 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时 @@ -162,6 +182,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: existingByName.show(false) return } + if (!(await claudeSessionFileExists(sessionId))) { + rejectMissingSessionFile(panel, sessionId, issueNumber) + return + } // 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create // 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已 // 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开 @@ -263,6 +287,21 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi existing.show(false) return } + // Scan live terminals before creating a new one — survives panel + // reload / webview rebuild where `this.reviewTerminals` got wiped but + // the tab is still open. + const reviewTerminalName = `issue-${issueNumber}-审查` + const existingByName = panel.findExistingTerminal(reviewTerminalName) + if (existingByName) { + panel.reviewTerminals.set(sessionId, existingByName) + panel.trackSessionTerminal(existingByName, issueNumber, 'review') + existingByName.show(false) + return + } + if (!(await codexSessionFileExists(sessionId))) { + rejectMissingSessionFile(panel, sessionId, issueNumber) + return + } // codex resume 只依赖 ~/.codex/sessions 下的 rollout 文件,与 worktree // 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与 // handleResumeSession 同款回退)。 @@ -289,17 +328,6 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi dismissOnTimer: 5000, }) } - // Scan live terminals before creating a new one — survives panel - // reload / webview rebuild where `this.reviewTerminals` got wiped but - // the tab is still open. - const reviewTerminalName = `issue-${issueNumber}-审查` - const existingByName = panel.findExistingTerminal(reviewTerminalName) - if (existingByName) { - panel.reviewTerminals.set(sessionId, existingByName) - panel.trackSessionTerminal(existingByName, issueNumber, 'review') - existingByName.show(false) - return - } // VS Code's default editor-tab icon (`>` arrow) does NOT honor the // `color` option. Force a `circle-filled` codicon so the tab actually // shows the issue color; keep `color` too for panel-view fallback. @@ -547,9 +575,8 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { // ~/.codex/sessions/YYYY/MM/DD/ on every run; we extract thread_id from // its name and persist it as reviewSessionId so the detail panel's // resume button works after the user closes this tab. - const codexSessionsRoot = path.join(os.homedir(), '.codex', 'sessions') const watcherPromise = watchForNewCodexSession({ - baseDir: codexSessionsRoot, + baseDir: defaultCodexSessionsDir(), timeoutMs: 120_000, }) @@ -599,12 +626,10 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { if (!token) return await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId }) - // reviewSessionFileExists 是计算字段不持久化;rollout 文件刚在本机 - // 落盘,patch 里直接置 true 让 resume 链接立即可点。 panel.postMessage({ type: 'issue/patch', issueNumber: opts.issueNumber, - patch: { reviewSessionId: threadId, reviewSessionFileExists: true }, + patch: { reviewSessionId: threadId }, }) } catch (err) { @@ -990,7 +1015,8 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN // 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。 const linked = panel.newIssueTerminals.get(issueNumber) const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName) - const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId }) + const sessionFileExists = !!sessionId && await claudeSessionFileExists(sessionId) + const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId, sessionFileExists }) if (continuePath === 'inject' && live) { panel.trackSessionTerminal(live, issueNumber, 'brainstorm') @@ -1390,6 +1416,10 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session existingByName.show(false) return } + if (!(await claudeSessionFileExists(sessionId))) { + rejectMissingSessionFile(panel, sessionId, issueNumber) + return + } // 测试会话 resume 新建终端时也要触发 pre-create,与实施 resume / 首次 // startTestSession 对称(开 pycharm 等);复用已有终端的 fast path 已在 @@ -1668,6 +1698,10 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se existingByName.show(false) return } + if (!(await claudeSessionFileExists(sessionId))) { + rejectMissingSessionFile(panel, sessionId, issueNumber) + return + } // 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向, // 与实施 / 测试 resume 一致。 diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index e794e06..a72d6d0 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, reviewSessionFileExists?: boolean, 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 } } /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } diff --git a/vscode/src/sessions/codexSessions.test.ts b/vscode/src/sessions/codexSessions.test.ts deleted file mode 100644 index 31b3a05..0000000 --- a/vscode/src/sessions/codexSessions.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { Issue } from '../gitea/types' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { annotateReviewSessionFileExists, listCodexSessionIds } from './codexSessions' - -const tempDirs: string[] = [] - -afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop() - if (dir) - rmSync(dir, { recursive: true, force: true }) - } -}) - -function tempDir(prefix: string): string { - const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) - tempDirs.push(dir) - return dir -} - -const UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' -const UUID_B = '11111111-2222-4333-8444-555555555555' - -function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void { - const dayDir = path.join(baseDir, ...ymd) - mkdirSync(dayDir, { recursive: true }) - writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n') -} - -describe('listCodexSessionIds', () => { - it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => { - const base = tempDir('codex-sessions-') - writeRollout(base, ['2026', '04', '27'], UUID_A) - writeRollout(base, ['2026', '05', '01'], UUID_B) - const ids = await listCodexSessionIds(base) - expect(ids).toEqual(new Set([UUID_A, UUID_B])) - }) - - it('忽略不符合 rollout-*-.jsonl 的文件', async () => { - const base = tempDir('codex-sessions-') - const dayDir = path.join(base, '2026', '04', '27') - mkdirSync(dayDir, { recursive: true }) - writeFileSync(path.join(dayDir, 'notes.txt'), 'x') - writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n') - writeRollout(base, ['2026', '04', '27'], UUID_A) - const ids = await listCodexSessionIds(base) - expect(ids).toEqual(new Set([UUID_A])) - }) - - it('目录不存在返回空 Set', async () => { - const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope')) - expect(ids.size).toBe(0) - }) -}) - -describe('annotateReviewSessionFileExists', () => { - const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue - - it('有会话文件的 issue 标 true,没有的标 false,无 reviewSessionId 的不加字段', async () => { - const base = tempDir('codex-sessions-') - writeRollout(base, ['2026', '04', '27'], UUID_A) - const issues: Issue[] = [ - { ...baseIssue, number: 1, reviewSessionId: UUID_A }, - { ...baseIssue, number: 2, reviewSessionId: UUID_B }, - { ...baseIssue, number: 3 }, - ] - const out = await annotateReviewSessionFileExists(issues, base) - expect(out[0].reviewSessionFileExists).toBe(true) - expect(out[1].reviewSessionFileExists).toBe(false) - expect('reviewSessionFileExists' in out[2]).toBe(false) - }) - - it('id 大小写不影响判定', async () => { - const base = tempDir('codex-sessions-') - writeRollout(base, ['2026', '04', '27'], UUID_A) - const out = await annotateReviewSessionFileExists( - [{ ...baseIssue, reviewSessionId: UUID_A.toUpperCase() }], - base, - ) - expect(out[0].reviewSessionFileExists).toBe(true) - }) -}) diff --git a/vscode/src/sessions/codexSessions.ts b/vscode/src/sessions/codexSessions.ts deleted file mode 100644 index 0f3da63..0000000 --- a/vscode/src/sessions/codexSessions.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 枚举本机 codex 会话文件,判定「审查会话id」能否 resume。 - * - * codex 把每个会话的 transcript 全局存在 - * `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`,resume 只依赖 - * 这个文件,与 worktree 无关——多人协作下 assignee 换机后,只要把会话文件 - * 拷到目标机器就能 resume。 - */ - -import type { Issue } from '../gitea/types' -import { promises as fsp } from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher' - -/** codex 会话根目录(全局,不随工作区变化)。 */ -export function defaultCodexSessionsDir(): string { - return path.join(os.homedir(), '.codex', 'sessions') -} - -/** - * 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id - * 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按 - * 「本机没有会话文件」处理即可。 - */ -export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise> { - const ids = new Set() - let entries: string[] - try { - entries = await fsp.readdir(baseDir, { recursive: true }) - } - catch { - return ids - } - for (const entry of entries) { - const m = path.basename(entry).match(ROLLOUT_UUID_REGEX) - if (m) - ids.add(m[1].toLowerCase()) - } - return ids -} - -/** - * 给带 reviewSessionId 的工单附加计算字段 `reviewSessionFileExists` - * (不落任何持久化)。整批工单只扫一次 sessions 目录;没有任何工单带 - * id 时连扫都不扫。 - */ -export async function annotateReviewSessionFileExists(issues: Issue[], baseDir?: string): Promise { - if (!issues.some(i => typeof i.reviewSessionId === 'string' && i.reviewSessionId.length > 0)) - return issues - const ids = await listCodexSessionIds(baseDir) - return issues.map(i => (i.reviewSessionId - ? { ...i, reviewSessionFileExists: ids.has(i.reviewSessionId.toLowerCase()) } - : i)) -} diff --git a/vscode/src/sessions/sessionFiles.test.ts b/vscode/src/sessions/sessionFiles.test.ts new file mode 100644 index 0000000..6130fe7 --- /dev/null +++ b/vscode/src/sessions/sessionFiles.test.ts @@ -0,0 +1,159 @@ +import type { Issue } from '../gitea/types' +import { promises as fsp, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + annotateMissingSessionIds, + claudeSessionFileExists, + codexSessionFileExists, + listClaudeSessionIds, + listCodexSessionIds, +} from './sessionFiles' + +const tempDirs: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' +const UUID_B = '11111111-2222-4333-8444-555555555555' +const SID_1 = 'aaaaaaaa-1111-4111-8111-111111111111' +const SID_2 = 'bbbbbbbb-2222-4222-8222-222222222222' +const SID_3 = 'cccccccc-3333-4333-8333-333333333333' + +function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void { + const dayDir = path.join(baseDir, ...ymd) + mkdirSync(dayDir, { recursive: true }) + writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n') +} + +function writeTranscript(projectsRoot: string, project: string, sid: string): void { + const dir = path.join(projectsRoot, project) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, `${sid}.jsonl`), '{}\n') +} + +describe('listCodexSessionIds', () => { + it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => { + const base = tempDir('codex-sessions-') + writeRollout(base, ['2026', '04', '27'], UUID_A) + writeRollout(base, ['2026', '05', '01'], UUID_B) + const ids = await listCodexSessionIds(base) + expect(ids).toEqual(new Set([UUID_A, UUID_B])) + }) + + it('忽略不符合 rollout-*-.jsonl 的文件', async () => { + const base = tempDir('codex-sessions-') + const dayDir = path.join(base, '2026', '04', '27') + mkdirSync(dayDir, { recursive: true }) + writeFileSync(path.join(dayDir, 'notes.txt'), 'x') + writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n') + writeRollout(base, ['2026', '04', '27'], UUID_A) + const ids = await listCodexSessionIds(base) + expect(ids).toEqual(new Set([UUID_A])) + }) + + it('目录不存在返回空 Set', async () => { + const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope')) + expect(ids.size).toBe(0) + }) +}) + +describe('listClaudeSessionIds', () => { + it('收集各 project 目录下的 .jsonl,统一小写', async () => { + const root = tempDir('claude-projects-') + writeTranscript(root, '-Users-a-proj', SID_1.toUpperCase()) + writeTranscript(root, '-Users-b-wt', SID_2) + const ids = await listClaudeSessionIds(root) + expect(ids).toEqual(new Set([SID_1, SID_2])) + }) + + it('不下钻 / 子目录,忽略非 jsonl 文件和根目录下的普通文件', async () => { + const root = tempDir('claude-projects-') + writeTranscript(root, '-Users-a-proj', SID_1) + const subagents = path.join(root, '-Users-a-proj', SID_1, 'subagents') + mkdirSync(subagents, { recursive: true }) + writeFileSync(path.join(subagents, `${SID_3}.jsonl`), '{}\n') + writeFileSync(path.join(root, '-Users-a-proj', 'notes.txt'), 'x') + writeFileSync(path.join(root, '.DS_Store'), 'x') + const ids = await listClaudeSessionIds(root) + expect(ids).toEqual(new Set([SID_1])) + }) + + it('根目录不存在返回空 Set', async () => { + const ids = await listClaudeSessionIds(path.join(tempDir('claude-projects-'), 'nope')) + expect(ids.size).toBe(0) + }) +}) + +describe('单个会话文件判定', () => { + it('claudeSessionFileExists:任一 project 目录下有 transcript 即为 true', async () => { + const root = tempDir('claude-projects-') + writeTranscript(root, '-Users-b-wt', SID_1) + expect(await claudeSessionFileExists(SID_1, root)).toBe(true) + expect(await claudeSessionFileExists(SID_2, root)).toBe(false) + }) + + it('codexSessionFileExists:按 rollout 文件名尾部 uuid 判定', async () => { + const base = tempDir('codex-sessions-') + writeRollout(base, ['2026', '04', '27'], UUID_A) + expect(await codexSessionFileExists(UUID_A.toUpperCase(), base)).toBe(true) + expect(await codexSessionFileExists(UUID_B, base)).toBe(false) + }) +}) + +describe('annotateMissingSessionIds', () => { + const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue + + it('claude + codex 混合:只列出本机没有会话文件的 id,全在本机的不加字段', async () => { + const claudeRoot = tempDir('claude-projects-') + const codexRoot = tempDir('codex-sessions-') + writeTranscript(claudeRoot, '-Users-a-proj', SID_1) + writeTranscript(claudeRoot, '-Users-b-wt', SID_2) + writeRollout(codexRoot, ['2026', '04', '27'], UUID_A) + const issues: Issue[] = [ + { ...baseIssue, number: 1, sessionId: SID_1.toUpperCase(), implementSessionId: SID_3, reviewSessionId: UUID_A }, + { ...baseIssue, number: 2, testSessionId: SID_3, prototypeSessionId: SID_2, reviewSessionId: UUID_B }, + { ...baseIssue, number: 3, sessionId: SID_2, reviewSessionId: UUID_A.toUpperCase() }, + { ...baseIssue, number: 4 }, + ] + const out = await annotateMissingSessionIds(issues, { claudeRoot, codexRoot }) + expect(out[0].missingSessionIds).toEqual([SID_3]) + expect(out[1].missingSessionIds).toEqual([SID_3, UUID_B]) + expect('missingSessionIds' in out[2]).toBe(false) + expect('missingSessionIds' in out[3]).toBe(false) + }) + + it('没有任何工单带会话 id 时一个目录都不扫', async () => { + const readdir = vi.spyOn(fsp, 'readdir') + const out = await annotateMissingSessionIds([{ ...baseIssue }], { + claudeRoot: tempDir('claude-projects-'), + codexRoot: tempDir('codex-sessions-'), + }) + expect(readdir).not.toHaveBeenCalled() + expect('missingSessionIds' in out[0]).toBe(false) + }) + + it('只带 claude 会话 id 时不扫 codex 目录', async () => { + const claudeRoot = tempDir('claude-projects-') + const codexRoot = tempDir('codex-sessions-') + const readdir = vi.spyOn(fsp, 'readdir') + const out = await annotateMissingSessionIds([{ ...baseIssue, sessionId: SID_1 }], { claudeRoot, codexRoot }) + expect(readdir.mock.calls.some(([p]) => p === codexRoot)).toBe(false) + expect(readdir.mock.calls.some(([p]) => p === claudeRoot)).toBe(true) + expect(out[0].missingSessionIds).toEqual([SID_1]) + }) +}) diff --git a/vscode/src/sessions/sessionFiles.ts b/vscode/src/sessions/sessionFiles.ts new file mode 100644 index 0000000..5e82012 --- /dev/null +++ b/vscode/src/sessions/sessionFiles.ts @@ -0,0 +1,121 @@ +/** + * 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。 + * + * 会话 id 存在 Gitea 共享状态里,别的机器上开的会话本机没有 transcript, + * resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。 + * + * - claude:`~/.claude/projects/<编码后的 cwd>/.jsonl`。新版 `claude --resume` + * 会搜全部 project 目录,任一 project 目录下有 `.jsonl` 即可 resume; + * `/` 子目录放的是 subagent 等附属文件,不用往下扫。 + * - codex:`~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`,全局存放, + * 与 worktree 无关。 + */ + +import type { Issue } from '../gitea/types' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher' +import { CLAUDE_SESSION_FIELDS } from '../cc/handoffManifest' +import { claudeProjectsRoot, codexRolloutRelPathFor, listClaudeSessionCopies } from '../cc/sessionBundle' + +/** codex 会话根目录(全局,不随工作区变化)。 */ +export function defaultCodexSessionsDir(): string { + return path.join(os.homedir(), '.codex', 'sessions') +} + +/** + * 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id + * 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按 + * 「本机没有会话文件」处理即可。 + */ +export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise> { + const ids = new Set() + let entries: string[] + try { + entries = await fsp.readdir(baseDir, { recursive: true }) + } + catch { + return ids + } + for (const entry of entries) { + const m = path.basename(entry).match(ROLLOUT_UUID_REGEX) + if (m) + ids.add(m[1].toLowerCase()) + } + return ids +} + +/** + * 扫 projects 根目录下每个 project 目录(只看这一层),收集 `.jsonl` + * 的会话 id(统一小写)。根目录不存在返回空 Set。 + */ +export async function listClaudeSessionIds(projectsRoot: string = claudeProjectsRoot()): Promise> { + const ids = new Set() + let projects: string[] + try { + projects = await fsp.readdir(projectsRoot) + } + catch { + return ids + } + await Promise.all(projects.map(async (name) => { + let files: string[] + try { + files = await fsp.readdir(path.join(projectsRoot, name)) + } + // 根目录下混着的普通文件(.DS_Store 等)readdir 抛 ENOTDIR,跳过 + catch { + return + } + for (const file of files) { + if (file.endsWith('.jsonl')) + ids.add(file.slice(0, -'.jsonl'.length).toLowerCase()) + } + })) + return ids +} + +/** 单个 claude 会话 id 本机是否有 transcript;resume 前的权威判定。 */ +export async function claudeSessionFileExists(sid: string, projectsRoot: string = claudeProjectsRoot()): Promise { + return (await listClaudeSessionCopies(sid, projectsRoot)).length > 0 +} + +/** 单个 codex 会话 id 本机是否有 rollout 文件;resume 前的权威判定。 */ +export async function codexSessionFileExists(sid: string, codexRoot: string = defaultCodexSessionsDir()): Promise { + return (await codexRolloutRelPathFor(sid, codexRoot)) !== undefined +} + +type SessionIdFields = Pick + +function claudeIdsOf(item: SessionIdFields): string[] { + return CLAUDE_SESSION_FIELDS + .map(({ field }) => item[field]) + .filter((id): id is string => typeof id === 'string' && id.length > 0) +} + +function codexIdsOf(item: SessionIdFields): string[] { + return item.reviewSessionId ? [item.reviewSessionId] : [] +} + +/** + * 给工单附加计算字段 `missingSessionIds`(不落任何持久化):它引用的会话 id + * 里本机没有会话文件的那些;全部能 resume 时不加字段。每种会话目录整批最多 + * 扫一次,没有任何工单引用该种 id 时连扫都不扫。 + */ +export async function annotateMissingSessionIds( + items: T[], + opts: { claudeRoot?: string, codexRoot?: string } = {}, +): Promise> { + const [claudeIds, codexIds] = await Promise.all([ + items.some(i => claudeIdsOf(i).length > 0) ? listClaudeSessionIds(opts.claudeRoot) : undefined, + items.some(i => codexIdsOf(i).length > 0) ? listCodexSessionIds(opts.codexRoot) : undefined, + ]) + return items.map((item) => { + const missing = [ + ...claudeIdsOf(item).filter(id => !claudeIds?.has(id.toLowerCase())), + ...codexIdsOf(item).filter(id => !codexIds?.has(id.toLowerCase())), + ] + return missing.length > 0 ? { ...item, missingSessionIds: missing } : item + }) +} diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index d8f01e7..9d22feb 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -27,7 +27,7 @@ import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from import { logger } from '../logging/logger' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' -import { annotateReviewSessionFileExists } from '../sessions/codexSessions' +import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getSettings } from '../settings/store' import { decideEventOwnership } from './eventOwnership' import { decideIssueAppend } from './issueAppend' @@ -686,7 +686,7 @@ class WebhookCoordinator { workspaceRoot: ws, issueNumber: event.issueNumber, }) - const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null + const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null if (issue) { this.activePanel.noteIssueOwnership(issue) this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) @@ -803,7 +803,7 @@ class WebhookCoordinator { workspaceRoot: ws, issueNumber, }) - const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null + const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null // 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新 // (含所在列),不在板上的新增——指派给我的工单借此实时上卡。 if (issue) { diff --git a/vscode/test/panel/brainstormContinue.test.ts b/vscode/test/panel/brainstormContinue.test.ts index 0519577..ffe3108 100644 --- a/vscode/test/panel/brainstormContinue.test.ts +++ b/vscode/test/panel/brainstormContinue.test.ts @@ -3,18 +3,26 @@ import { pickBrainstormContinuePath } from '../../src/panel/brainstormContinue' describe('pickBrainstormContinuePath', () => { it('types the prompt into the live terminal when the brainstorm tab is still open', () => { - expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc' })).toBe('inject') + expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc', sessionFileExists: true })).toBe('inject') }) it('resumes the recorded session with the prompt when the tab is closed', () => { - expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc' })).toBe('resume') + expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc', sessionFileExists: true })).toBe('resume') + }) + + it('starts a fresh session when the recorded session has no transcript on this machine', () => { + expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc', sessionFileExists: false })).toBe('fresh') }) it('starts a fresh session when the issue has no brainstorm session yet', () => { - expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: undefined })).toBe('fresh') + expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: undefined, sessionFileExists: false })).toBe('fresh') }) it('prefers the live terminal even when no session id has been recorded yet', () => { - expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: undefined })).toBe('inject') + expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: undefined, sessionFileExists: false })).toBe('inject') + }) + + it('prefers the live terminal even when the session transcript is not on this machine', () => { + expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc', sessionFileExists: false })).toBe('inject') }) }) diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index bea94a2..1682da0 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -15,6 +15,7 @@ import { useManagedSessions } from './hooks/useManagedSessions' import { useProfiles } from './hooks/useProfiles' import { filterIssues } from './lib/issueFilter' import { compareIssuesInColumn } from './lib/issueSort' +import { isSessionMissing } from './lib/missingSession' import { COLUMN_ORDER } from './types' export function App() { @@ -231,10 +232,17 @@ export function App() { } if (e.key === 'Enter') { - const sid = selectedIssue?.implementSessionId || selectedIssue?.sessionId + // 本机没有会话文件的 id(别的机器上开的会话)resume 必失败,跳过。 + const implSid = isSessionMissing(selectedIssue, selectedIssue?.implementSessionId) + ? undefined + : selectedIssue?.implementSessionId + const brainstormSid = isSessionMissing(selectedIssue, selectedIssue?.sessionId) + ? undefined + : selectedIssue?.sessionId + const sid = implSid || brainstormSid if (sid) { e.preventDefault() - const isImpl = !!selectedIssue?.implementSessionId + const isImpl = !!implSid const cwd = isImpl ? selectedIssue?.worktreePath : undefined // 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。 const profileForResume = isImpl diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index d750117..db11eed 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -5,11 +5,15 @@ import { ArrowRightLeft, CircleSlash, Download, Eraser, ExternalLink, GitMerge, import { useMemo, useRef } from 'react' import { isIssueLocked } from '../lib/dependencies' import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership' +import { isSessionMissing } from '../lib/missingSession' import { toReadOnlySchema } from '../lib/readOnlySchema' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { PropertyGrid } from './property-grid' import { Tooltip } from './ui/tooltip' +/** 会话 id 在共享状态里、本机却没有会话文件时,各会话行的统一说明。 */ +const MISSING_SESSION_DESC = '本机没有该会话文件(会话在别的机器上创建),无法 resume' + /** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */ function matchProfileOption( current: string | undefined, @@ -194,10 +198,10 @@ export function IssueDetailPanel({ : undefined const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false - // 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表, + // 各会话 id 行(头脑风暴/实施/审查/测试/原型)共用一个"上次触发时间戳"表, // 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal // 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。 - // key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。 + // key 用 sessionId 字符串本身(足够区分不同行 + 不同工单)。 const lastFiredAtRef = useRef>(new Map()) const RESUME_THROTTLE_MS = 800 const throttleBySessionId = (sid: string): boolean => { @@ -242,9 +246,12 @@ export function IssueDetailPanel({ // - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话: // 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。 // - brainstormTabOpen → 再加一个 X 关闭 tab + // - 会话 id 本机没有会话文件 → 只读不可双击,续聊会新开会话 const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) + const missing = isSessionMissing(issue, issue?.sessionId) + const canContinue = issue?.column !== 'done' const secondaryActions: RowAction[] = [] - if (issue?.column !== 'done') { + if (canContinue) { secondaryActions.push({ icon: , title: '续聊:把续聊提示词发给头脑风暴会话', @@ -274,6 +281,17 @@ export function IssueDetailPanel({ }, }) } + if (missing) { + return { + key: 'sessionId', + label: '头脑风暴会话id', + type: 'action' as const, + readOnly: true, + description: canContinue ? `${MISSING_SESSION_DESC};点 ▶ 续聊会新开一个规划会话` : MISSING_SESSION_DESC, + actionIcon: , + secondaryActions, + } + } return { key: 'sessionId', label: '头脑风暴会话id', @@ -293,27 +311,14 @@ export function IssueDetailPanel({ 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 恢复实施对话(cwd 为 worktree)' - : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', - actionIcon: , - onAction: (v) => { - if (typeof v !== 'string' || v.length === 0) - return - if (!throttleBySessionId(v)) - return - onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number) - }, - secondaryActions: (() => { + (() => { + // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口: + // extension 端 handleResumeSession 会在 worktree 路径不存在时退回 + // 工作区根目录,并通过 toast 告知用户;description 在两种状态下 + // 给出不同的提示文案。 + // 本机没有会话文件时只读且不给 ▶:重新实施会先删远程 feature 分支, + // 会把别的机器上的实施进度一并抹掉。 + const secondaryActions: RowAction[] = (() => { if (issue?.implementTabOpen) { return [{ icon: , @@ -333,9 +338,38 @@ export function IssueDetailPanel({ || locked, onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId), }] - })(), - }, - issue?.reviewSessionFileExists + })() + if (isSessionMissing(issue, issue?.implementSessionId)) { + return { + key: 'implementSessionId', + label: '实施会话id', + type: 'action' as const, + readOnly: true, + description: `${MISSING_SESSION_DESC};要在本机接着实施,请让对方把工单移交过来`, + actionIcon: , + secondaryActions, + } + } + return { + key: 'implementSessionId', + label: '实施会话id', + type: 'action' as const, + pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false, + description: issue?.worktreeExists + ? '双击在新终端运行 claude --resume 恢复实施对话(cwd 为 worktree)' + : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', + actionIcon: , + onAction: (v: unknown) => { + if (typeof v !== 'string' || v.length === 0) + return + if (!throttleBySessionId(v)) + return + onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number) + }, + secondaryActions, + } + })(), + issue?.reviewSessionId && !isSessionMissing(issue, issue.reviewSessionId) ? { key: 'reviewSessionId', label: '审查会话id', @@ -364,7 +398,7 @@ export function IssueDetailPanel({ type: 'action', readOnly: true, description: issue?.reviewSessionId - ? '本机没有该 codex 会话文件,无法 resume;点 ▶ 对当前 PR 重新起一个审查会话' + ? `${MISSING_SESSION_DESC};点 ▶ 对当前 PR 重新起一个审查会话` : '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)', secondaryActions: issue?.pr ? [{ @@ -383,8 +417,11 @@ export function IssueDetailPanel({ // 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测, // 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区, // 那条路径要靠 PR 让 cc 了解合并进来的改动。 + // 会话 id 本机没有会话文件 → 只读不可双击,按状态 3 的条件给 ▶ 重新起一个 + // (启动会覆盖旧 testSessionId)。 const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) - const canStart = !hasSession && (!!issue?.worktreeExists || !!issue?.pr) + const missing = isSessionMissing(issue, issue?.testSessionId) + const canStart = (!hasSession || missing) && (!!issue?.worktreeExists || !!issue?.pr) const secondaryActions: RowAction[] = [] if (hasSession && issue?.testTabOpen) { secondaryActions.push({ @@ -406,6 +443,17 @@ export function IssueDetailPanel({ }, }) } + if (missing) { + return { + key: 'testSessionId', + label: '测试会话id', + type: 'action' as const, + readOnly: true, + description: `${MISSING_SESSION_DESC};点 ▶ 新起一个测试会话(worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR)`, + actionIcon: , + secondaryActions, + } + } return { key: 'testSessionId', label: '测试会话id', @@ -428,7 +476,10 @@ export function IssueDetailPanel({ (() => { // 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照 // 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。 + // 会话 id 本机没有会话文件 → 只读不可双击,同样给 ▶ 重新起一个 + // (启动会覆盖旧 prototypeSessionId)。 const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0) + const missing = isSessionMissing(issue, issue?.prototypeSessionId) const secondaryActions: RowAction[] = [] if (hasSession && issue?.prototypeTabOpen) { secondaryActions.push({ @@ -440,7 +491,7 @@ export function IssueDetailPanel({ }, }) } - else if (!hasSession) { + else if (!hasSession || missing) { secondaryActions.push({ icon: , title: locked ? lockTitle : '启动原型会话', @@ -451,6 +502,17 @@ export function IssueDetailPanel({ }, }) } + if (missing) { + return { + key: 'prototypeSessionId', + label: '原型会话id', + type: 'action' as const, + readOnly: true, + description: `${MISSING_SESSION_DESC};点 ▶ 新起一个原型会话`, + actionIcon: , + secondaryActions, + } + } return { key: 'prototypeSessionId', label: '原型会话id', diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index fb1675c..80d1b01 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, reviewSessionFileExists?: boolean, 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 } } /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } diff --git a/vscode/webview-ui/src/lib/missingSession.test.ts b/vscode/webview-ui/src/lib/missingSession.test.ts new file mode 100644 index 0000000..6e7ea0e --- /dev/null +++ b/vscode/webview-ui/src/lib/missingSession.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isSessionMissing } from './missingSession' + +describe('isSessionMissing', () => { + it('会话 id 在缺失列表里(忽略大小写)→ true', () => { + expect(isSessionMissing({ missingSessionIds: ['ABC'] }, 'abc')).toBe(true) + }) + + it('不在缺失列表、列表缺省、id 为空 → false', () => { + expect(isSessionMissing({ missingSessionIds: ['abc'] }, 'def')).toBe(false) + expect(isSessionMissing({}, 'abc')).toBe(false) + expect(isSessionMissing(undefined, 'abc')).toBe(false) + expect(isSessionMissing({ missingSessionIds: ['abc'] }, undefined)).toBe(false) + }) +}) diff --git a/vscode/webview-ui/src/lib/missingSession.ts b/vscode/webview-ui/src/lib/missingSession.ts new file mode 100644 index 0000000..07751be --- /dev/null +++ b/vscode/webview-ui/src/lib/missingSession.ts @@ -0,0 +1,9 @@ +import type { Issue } from '../types' + +/** + * 会话 id 在本机没有会话文件(别的机器上开的会话),resume 必失败。 + * 详情面板的会话行和看板的键盘 resume 快捷键都据此跳过。 + */ +export function isSessionMissing(issue: Pick | null | undefined, sid: string | undefined): boolean { + return !!sid && !!issue?.missingSessionIds?.some(m => m.toLowerCase() === sid.toLowerCase()) +} diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index 0caecd0..84167dd 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -71,10 +71,10 @@ export interface Issue { /** Backend-agnostic review session id (v1 stores a codex thread id). */ reviewSessionId?: string /** - * 本机是否存在 reviewSessionId 对应的 codex 会话文件;扩展端加载时计算, - * 不持久化。codex resume 只依赖这个文件,与 worktree 无关。 + * 本工单引用的会话 id 里本机没有会话文件的那些(claude 四种 + codex 审查); + * 扩展端加载时计算,不持久化。缺省或空 = 全部能 resume。 */ - reviewSessionFileExists?: boolean + missingSessionIds?: string[] /** * Claude Code session id of the test conversation; manually started after * the PR is merged, resumable from the detail panel.