🐛 fix(vscode): 别的机器上开的会话在本机没有会话文件时,会话 id 行置灰不可双击,不再 resume 报错

This commit is contained in:
cruldra committed 2026-09-24 19:22:29 +08:00
1 parent 2b1e660cc8
commit bf4661e364
20 files changed
+508 -234

No files matched your search

+5 -4
View File
@@ -98,11 +98,12 @@ export interface Issue {
*/ */
reviewSessionId?: string reviewSessionId?: string
/** /**
* Whether the codex session file (`~/.codex/sessions/YYYY/MM/DD/rollout-...-<id>.jsonl`) * 本工单引用的会话 id(sessionId / implementSessionId / testSessionId /
* for `reviewSessionId` exists on this machine; computed on load, never * prototypeSessionId 走 claude,reviewSessionId 走 codex)里本机没有会话
* persisted. codex resume 只依赖这个文件,与 worktree 无关。 * 文件的那些;加载时计算,不持久化。会话 id 来自共享状态,别的机器上开的
* 会话本机 resume 不了。缺省或空 = 全部能 resume。
*/ */
reviewSessionFileExists?: boolean missingSessionIds?: string[]
/** /**
* Session id of the test conversation (a Claude Code session id). Set the * 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 * first time the user manually starts a test session after the issue's PR
+4 -4
View File
@@ -25,7 +25,7 @@ import { loadIssues } from '../gitea/issueLoader'
import { overlayLocalIssueState } from '../issues/localState' import { overlayLocalIssueState } from '../issues/localState'
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter' import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { annotateMissingSessionIds } from '../sessions/sessionFiles'
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store' import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store'
import { webhookCoordinator } from '../webhook/coordinator' import { webhookCoordinator } from '../webhook/coordinator'
import { loadYouTrackIssues } from '../youtrack/issueLoader' import { loadYouTrackIssues } from '../youtrack/issueLoader'
@@ -1048,10 +1048,10 @@ export class KanbanWebviewPanel {
loadIssues({ host, token, owner, repo, workspaceRoot, scope }), loadIssues({ host, token, owner, repo, workspaceRoot, scope }),
this.loadYouTrackSafe(), this.loadYouTrackSafe(),
]) ])
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex // 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有
// 会话文件存在性,再补 live terminal 态。 // 会话文件的会话 id,再补 live terminal 态。
const issues = this.withLiveTerminalTabState( const issues = this.withLiveTerminalTabState(
await annotateReviewSessionFileExists( await annotateMissingSessionIds(
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot), overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
), ),
) )
+4 -3
View File
@@ -1,14 +1,15 @@
/** /**
* 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有 * 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有
* 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有 * 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有
* sessionId 就 `--resume` 并带上提示词;两者都没有才新开会话。 * sessionId 且本机有它的会话文件就 `--resume` 并带上提示词;否则新开会话
* ——会话 id 来自共享状态,别的机器上开的会话本机 resume 不了。
*/ */
export type BrainstormContinuePath = 'inject' | 'resume' | 'fresh' 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) if (opts.liveTerminal)
return 'inject' return 'inject'
if (opts.sessionId) if (opts.sessionId && opts.sessionFileExists)
return 'resume' return 'resume'
return 'fresh' return 'fresh'
} }
+9 -12
View File
@@ -153,12 +153,14 @@ describe('payloads', () => {
profiles: {}, profiles: {},
worktreePath: '/wt', worktreePath: '/wt',
worktreeExists: true, worktreeExists: true,
missingSessionIds: [],
})).toEqual({ })).toEqual({
handoffAttachmentId: null, handoffAttachmentId: null,
handoffFrom: null, handoffFrom: null,
sessionId: 's', sessionId: 's',
worktreePath: '/wt', worktreePath: '/wt',
worktreeExists: true, worktreeExists: true,
missingSessionIds: [],
}) })
}) })
it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => { it('接管 UI patch:无 worktreePath 时显式置 null,不留旧值', () => {
@@ -166,35 +168,30 @@ describe('payloads', () => {
sessions: { sessionId: 's' }, sessions: { sessionId: 's' },
profiles: {}, profiles: {},
worktreeExists: false, worktreeExists: false,
missingSessionIds: [],
})).toEqual({ })).toEqual({
handoffAttachmentId: null, handoffAttachmentId: null,
handoffFrom: null, handoffFrom: null,
sessionId: 's', sessionId: 's',
worktreePath: null, worktreePath: null,
worktreeExists: false, worktreeExists: false,
missingSessionIds: [],
}) })
}) })
it('接管 UI patch:带 reviewSessionFileExists 时一并下发,UI 不用等下次刷新才能点链接', () => { it('接管 UI patch:缺失的会话 id 原样下发,UI 立刻知道哪几行不能 resume', () => {
expect(handoffAcceptedUiPatch({ expect(handoffAcceptedUiPatch({
sessions: { reviewSessionId: 'r' }, sessions: { sessionId: 's', reviewSessionId: 'r' },
profiles: {}, profiles: {},
worktreeExists: false, worktreeExists: false,
reviewSessionFileExists: true, missingSessionIds: ['r'],
})).toEqual({ })).toEqual({
handoffAttachmentId: null, handoffAttachmentId: null,
handoffFrom: null, handoffFrom: null,
sessionId: 's',
reviewSessionId: 'r', reviewSessionId: 'r',
worktreePath: null, worktreePath: null,
worktreeExists: false, 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')
})
}) })
+3 -2
View File
@@ -110,7 +110,7 @@ export function handoffAcceptedUiPatch(local: {
profiles: HandoffProfiles profiles: HandoffProfiles
worktreePath?: string worktreePath?: string
worktreeExists: boolean worktreeExists: boolean
reviewSessionFileExists?: boolean missingSessionIds: string[]
}): Record<string, unknown> { }): Record<string, unknown> {
return { return {
handoffAttachmentId: null, handoffAttachmentId: null,
@@ -121,6 +121,7 @@ export function handoffAcceptedUiPatch(local: {
// 与 worktreeExists: false 对不上。 // 与 worktreeExists: false 对不上。
worktreePath: local.worktreePath ?? null, worktreePath: local.worktreePath ?? null,
worktreeExists: local.worktreeExists, worktreeExists: local.worktreeExists,
...(local.reviewSessionFileExists !== undefined ? { reviewSessionFileExists: local.reviewSessionFileExists } : {}), // 同理空数组也要发:webview 里留着接管前(会话文件还没装进本机)算的缺失列表。
missingSessionIds: local.missingSessionIds,
} }
} }
+5 -7
View File
@@ -28,7 +28,7 @@ import {
uploadIssueAttachment, uploadIssueAttachment,
} from '../../gitea/api' } from '../../gitea/api'
import { logger } from '../../logging/logger' import { logger } from '../../logging/logger'
import { defaultCodexSessionsDir } from '../../sessions/codexSessions' import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store' import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup' import { cleanupFeatureBranch } from './branchCleanup'
@@ -464,11 +464,9 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
}) })
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) // ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
// reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists); // missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机,
// 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。 // 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。
const reviewSessionId = manifest.sessions.reviewSessionId const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions])
const reviewSessionFileExists = !!reviewSessionId
&& manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase())
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
try { try {
@@ -482,7 +480,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
panel.postMessage({ panel.postMessage({
type: 'issue/patch', type: 'issue/patch',
issueNumber, issueNumber,
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }), patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, missingSessionIds }),
}) })
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`) toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) }) logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) })
+52 -18
View File
@@ -4,7 +4,6 @@ import type { ResumeKind } from './resumeTarget'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs' import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
import { window, workspace } from 'vscode' import { window, workspace } from 'vscode'
import { loginForToken } from '../../auth/identity' 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 { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'
import { updateIssueAssignees } from '../../gitea/api' import { updateIssueAssignees } from '../../gitea/api'
import { logger } from '../../logging/logger' import { logger } from '../../logging/logger'
import { claudeSessionFileExists, codexSessionFileExists, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store' import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator' import { webhookCoordinator } from '../../webhook/coordinator'
import { decideAutoReviewPath } from '../autoReviewDecision' import { decideAutoReviewPath } from '../autoReviewDecision'
@@ -57,6 +57,26 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : 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<void> { export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, sessionKind: ResumeKind, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
// In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s) // In-flight 锁:防 impl-tab-pre-create 钩子 await(pgrep pycharm 最多 15s)
// 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时 // 期间用户重复点同一会话 id 触发重复 createTerminal。issueNumber 为空时
@@ -162,6 +182,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
existingByName.show(false) existingByName.show(false)
return return
} }
if (!(await claudeSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create // 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create
// 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已 // 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已
// 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开 // 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开
@@ -263,6 +287,21 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
existing.show(false) existing.show(false)
return 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 // codex resume 只依赖 ~/.codex/sessions 下的 rollout 文件,与 worktree
// 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与 // 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与
// handleResumeSession 同款回退)。 // handleResumeSession 同款回退)。
@@ -289,17 +328,6 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
dismissOnTimer: 5000, 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 // VS Code's default editor-tab icon (`>` arrow) does NOT honor the
// `color` option. Force a `circle-filled` codicon so the tab actually // `color` option. Force a `circle-filled` codicon so the tab actually
// shows the issue color; keep `color` too for panel-view fallback. // 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 // ~/.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 // its name and persist it as reviewSessionId so the detail panel's
// resume button works after the user closes this tab. // resume button works after the user closes this tab.
const codexSessionsRoot = path.join(os.homedir(), '.codex', 'sessions')
const watcherPromise = watchForNewCodexSession({ const watcherPromise = watchForNewCodexSession({
baseDir: codexSessionsRoot, baseDir: defaultCodexSessionsDir(),
timeoutMs: 120_000, timeoutMs: 120_000,
}) })
@@ -599,12 +626,10 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
if (!token) if (!token)
return return
await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId }) await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId })
// reviewSessionFileExists 是计算字段不持久化;rollout 文件刚在本机
// 落盘,patch 里直接置 true 让 resume 链接立即可点。
panel.postMessage({ panel.postMessage({
type: 'issue/patch', type: 'issue/patch',
issueNumber: opts.issueNumber, issueNumber: opts.issueNumber,
patch: { reviewSessionId: threadId, reviewSessionFileExists: true }, patch: { reviewSessionId: threadId },
}) })
} }
catch (err) { catch (err) {
@@ -990,7 +1015,8 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。 // 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
const linked = panel.newIssueTerminals.get(issueNumber) const linked = panel.newIssueTerminals.get(issueNumber)
const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName) 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) { if (continuePath === 'inject' && live) {
panel.trackSessionTerminal(live, issueNumber, 'brainstorm') panel.trackSessionTerminal(live, issueNumber, 'brainstorm')
@@ -1390,6 +1416,10 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
existingByName.show(false) existingByName.show(false)
return return
} }
if (!(await claudeSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// 测试会话 resume 新建终端时也要触发 pre-create,与实施 resume / 首次 // 测试会话 resume 新建终端时也要触发 pre-create,与实施 resume / 首次
// startTestSession 对称(开 pycharm 等);复用已有终端的 fast path 已在 // startTestSession 对称(开 pycharm 等);复用已有终端的 fast path 已在
@@ -1668,6 +1698,10 @@ export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, se
existingByName.show(false) existingByName.show(false)
return return
} }
if (!(await claudeSessionFileExists(sessionId))) {
rejectMissingSessionFile(panel, sessionId, issueNumber)
return
}
// 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向, // 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向,
// 与实施 / 测试 resume 一致。 // 与实施 / 测试 resume 一致。
+1 -1
View File
@@ -52,7 +52,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string } | { 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。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
-85
View File
@@ -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-*-<uuid>.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)
})
})
-55
View File
@@ -1,55 +0,0 @@
/**
* 枚举本机 codex 会话文件,判定「审查会话id」能否 resume。
*
* codex 把每个会话的 transcript 全局存在
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.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<Set<string>> {
const ids = new Set<string>()
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<Issue[]> {
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))
}
+159
View File
@@ -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-*-<uuid>.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 目录下的 <sid>.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('不下钻 <sid>/ 子目录,忽略非 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])
})
})
+121
View File
@@ -0,0 +1,121 @@
/**
* 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。
*
* 会话 id 存在 Gitea 共享状态里,别的机器上开的会话本机没有 transcript,
* resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。
*
* - claude:`~/.claude/projects/<编码后的 cwd>/<sid>.jsonl`。新版 `claude --resume`
* 会搜全部 project 目录,任一 project 目录下有 `<sid>.jsonl` 即可 resume;
* `<sid>/` 子目录放的是 subagent 等附属文件,不用往下扫。
* - codex:`~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.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<Set<string>> {
const ids = new Set<string>()
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 目录(只看这一层),收集 `<sid>.jsonl`
* 的会话 id(统一小写)。根目录不存在返回空 Set。
*/
export async function listClaudeSessionIds(projectsRoot: string = claudeProjectsRoot()): Promise<Set<string>> {
const ids = new Set<string>()
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<boolean> {
return (await listClaudeSessionCopies(sid, projectsRoot)).length > 0
}
/** 单个 codex 会话 id 本机是否有 rollout 文件;resume 前的权威判定。 */
export async function codexSessionFileExists(sid: string, codexRoot: string = defaultCodexSessionsDir()): Promise<boolean> {
return (await codexRolloutRelPathFor(sid, codexRoot)) !== undefined
}
type SessionIdFields = Pick<Issue, 'sessionId' | 'implementSessionId' | 'testSessionId' | 'prototypeSessionId' | 'reviewSessionId'>
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<T extends SessionIdFields>(
items: T[],
opts: { claudeRoot?: string, codexRoot?: string } = {},
): Promise<Array<T & { missingSessionIds?: string[] }>> {
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
})
}
+3 -3
View File
@@ -27,7 +27,7 @@ import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' 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 { getSettings } from '../settings/store'
import { decideEventOwnership } from './eventOwnership' import { decideEventOwnership } from './eventOwnership'
import { decideIssueAppend } from './issueAppend' import { decideIssueAppend } from './issueAppend'
@@ -686,7 +686,7 @@ class WebhookCoordinator {
workspaceRoot: ws, workspaceRoot: ws,
issueNumber: event.issueNumber, 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) { if (issue) {
this.activePanel.noteIssueOwnership(issue) this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
@@ -803,7 +803,7 @@ class WebhookCoordinator {
workspaceRoot: ws, workspaceRoot: ws,
issueNumber, 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 去重原位替换:已在板上的卡就地刷新 // 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。 // (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
if (issue) { if (issue) {
+12 -4
View File
@@ -3,18 +3,26 @@ import { pickBrainstormContinuePath } from '../../src/panel/brainstormContinue'
describe('pickBrainstormContinuePath', () => { describe('pickBrainstormContinuePath', () => {
it('types the prompt into the live terminal when the brainstorm tab is still open', () => { 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', () => { 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', () => { 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', () => { 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')
}) })
}) })
+10 -2
View File
@@ -15,6 +15,7 @@ import { useManagedSessions } from './hooks/useManagedSessions'
import { useProfiles } from './hooks/useProfiles' import { useProfiles } from './hooks/useProfiles'
import { filterIssues } from './lib/issueFilter' import { filterIssues } from './lib/issueFilter'
import { compareIssuesInColumn } from './lib/issueSort' import { compareIssuesInColumn } from './lib/issueSort'
import { isSessionMissing } from './lib/missingSession'
import { COLUMN_ORDER } from './types' import { COLUMN_ORDER } from './types'
export function App() { export function App() {
@@ -231,10 +232,17 @@ export function App() {
} }
if (e.key === 'Enter') { 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) { if (sid) {
e.preventDefault() e.preventDefault()
const isImpl = !!selectedIssue?.implementSessionId const isImpl = !!implSid
const cwd = isImpl ? selectedIssue?.worktreePath : undefined const cwd = isImpl ? selectedIssue?.worktreePath : undefined
// 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。 // 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。
const profileForResume = isImpl const profileForResume = isImpl
@@ -5,11 +5,15 @@ import { ArrowRightLeft, CircleSlash, Download, Eraser, ExternalLink, GitMerge,
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { isIssueLocked } from '../lib/dependencies' import { isIssueLocked } from '../lib/dependencies'
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership' import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
import { isSessionMissing } from '../lib/missingSession'
import { toReadOnlySchema } from '../lib/readOnlySchema' import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid' import { PropertyGrid } from './property-grid'
import { Tooltip } from './ui/tooltip' import { Tooltip } from './ui/tooltip'
/** 会话 id 在共享状态里、本机却没有会话文件时,各会话行的统一说明。 */
const MISSING_SESSION_DESC = '本机没有该会话文件(会话在别的机器上创建),无法 resume'
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */ /** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
function matchProfileOption( function matchProfileOption(
current: string | undefined, current: string | undefined,
@@ -194,10 +198,10 @@ export function IssueDetailPanel({
: undefined : undefined
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表, // 各会话 id 行(头脑风暴/实施/审查/测试/原型)共用一个"上次触发时间戳"表,
// 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal // 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。 // 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。 // key 用 sessionId 字符串本身(足够区分不同行 + 不同工单)。
const lastFiredAtRef = useRef<Map<string, number>>(new Map()) const lastFiredAtRef = useRef<Map<string, number>>(new Map())
const RESUME_THROTTLE_MS = 800 const RESUME_THROTTLE_MS = 800
const throttleBySessionId = (sid: string): boolean => { const throttleBySessionId = (sid: string): boolean => {
@@ -242,9 +246,12 @@ export function IssueDetailPanel({
// - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话: // - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话:
// 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。 // 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。
// - brainstormTabOpen → 再加一个 X 关闭 tab // - brainstormTabOpen → 再加一个 X 关闭 tab
// - 会话 id 本机没有会话文件 → 只读不可双击,续聊会新开会话
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
const missing = isSessionMissing(issue, issue?.sessionId)
const canContinue = issue?.column !== 'done'
const secondaryActions: RowAction[] = [] const secondaryActions: RowAction[] = []
if (issue?.column !== 'done') { if (canContinue) {
secondaryActions.push({ secondaryActions.push({
icon: <Play className="size-3.5" />, icon: <Play className="size-3.5" />,
title: '续聊:把续聊提示词发给头脑风暴会话', 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: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return { return {
key: 'sessionId', key: 'sessionId',
label: '头脑风暴会话id', label: '头脑风暴会话id',
@@ -293,27 +311,14 @@ export function IssueDetailPanel({
secondaryActions, secondaryActions,
} }
})(), })(),
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口: (() => {
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回 // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下 // extension 端 handleResumeSession 会在 worktree 路径不存在时退回
// 给出不同的提示文案。 // 工作区根目录,并通过 toast 告知用户;description 在两种状态下
{ // 给出不同的提示文案。
key: 'implementSessionId', // 本机没有会话文件时只读且不给 ▶:重新实施会先删远程 feature 分支,
label: '实施会话id', // 会把别的机器上的实施进度一并抹掉。
type: 'action', const secondaryActions: RowAction[] = (() => {
pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false,
description: issue?.worktreeExists
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v) => {
if (typeof v !== 'string' || v.length === 0)
return
if (!throttleBySessionId(v))
return
onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
},
secondaryActions: (() => {
if (issue?.implementTabOpen) { if (issue?.implementTabOpen) {
return [{ return [{
icon: <X className="size-3.5" />, icon: <X className="size-3.5" />,
@@ -333,9 +338,38 @@ export function IssueDetailPanel({
|| locked, || locked,
onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId), onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId),
}] }]
})(), })()
}, if (isSessionMissing(issue, issue?.implementSessionId)) {
issue?.reviewSessionFileExists return {
key: 'implementSessionId',
label: '实施会话id',
type: 'action' as const,
readOnly: true,
description: `${MISSING_SESSION_DESC};要在本机接着实施,请让对方把工单移交过来`,
actionIcon: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return {
key: 'implementSessionId',
label: '实施会话id',
type: 'action' as const,
pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false,
description: issue?.worktreeExists
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0)
return
if (!throttleBySessionId(v))
return
onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
},
secondaryActions,
}
})(),
issue?.reviewSessionId && !isSessionMissing(issue, issue.reviewSessionId)
? { ? {
key: 'reviewSessionId', key: 'reviewSessionId',
label: '审查会话id', label: '审查会话id',
@@ -364,7 +398,7 @@ export function IssueDetailPanel({
type: 'action', type: 'action',
readOnly: true, readOnly: true,
description: issue?.reviewSessionId description: issue?.reviewSessionId
? '本机没有该 codex 会话文件,无法 resume;点 ▶ 对当前 PR 重新起一个审查会话' ? `${MISSING_SESSION_DESC};点 ▶ 对当前 PR 重新起一个审查会话`
: '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)', : '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)',
secondaryActions: issue?.pr secondaryActions: issue?.pr
? [{ ? [{
@@ -383,8 +417,11 @@ export function IssueDetailPanel({
// 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测, // 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测,
// 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区, // 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区,
// 那条路径要靠 PR 让 cc 了解合并进来的改动。 // 那条路径要靠 PR 让 cc 了解合并进来的改动。
// 会话 id 本机没有会话文件 → 只读不可双击,按状态 3 的条件给 ▶ 重新起一个
// (启动会覆盖旧 testSessionId)。
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) 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[] = [] const secondaryActions: RowAction[] = []
if (hasSession && issue?.testTabOpen) { if (hasSession && issue?.testTabOpen) {
secondaryActions.push({ 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: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return { return {
key: 'testSessionId', key: 'testSessionId',
label: '测试会话id', label: '测试会话id',
@@ -428,7 +476,10 @@ export function IssueDetailPanel({
(() => { (() => {
// 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照 // 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照
// 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。 // 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。
// 会话 id 本机没有会话文件 → 只读不可双击,同样给 ▶ 重新起一个
// (启动会覆盖旧 prototypeSessionId)。
const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0) const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0)
const missing = isSessionMissing(issue, issue?.prototypeSessionId)
const secondaryActions: RowAction[] = [] const secondaryActions: RowAction[] = []
if (hasSession && issue?.prototypeTabOpen) { if (hasSession && issue?.prototypeTabOpen) {
secondaryActions.push({ secondaryActions.push({
@@ -440,7 +491,7 @@ export function IssueDetailPanel({
}, },
}) })
} }
else if (!hasSession) { else if (!hasSession || missing) {
secondaryActions.push({ secondaryActions.push({
icon: <Play className="size-3.5" />, icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '启动原型会话', 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: <Terminal className="size-3.5" />,
secondaryActions,
}
}
return { return {
key: 'prototypeSessionId', key: 'prototypeSessionId',
label: '原型会话id', label: '原型会话id',
+1 -1
View File
@@ -83,7 +83,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string } | { 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。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -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)
})
})
@@ -0,0 +1,9 @@
import type { Issue } from '../types'
/**
* 会话 id 在本机没有会话文件(别的机器上开的会话),resume 必失败。
* 详情面板的会话行和看板的键盘 resume 快捷键都据此跳过。
*/
export function isSessionMissing(issue: Pick<Issue, 'missingSessionIds'> | null | undefined, sid: string | undefined): boolean {
return !!sid && !!issue?.missingSessionIds?.some(m => m.toLowerCase() === sid.toLowerCase())
}
+3 -3
View File
@@ -71,10 +71,10 @@ export interface Issue {
/** Backend-agnostic review session id (v1 stores a codex thread id). */ /** Backend-agnostic review session id (v1 stores a codex thread id). */
reviewSessionId?: string reviewSessionId?: string
/** /**
* 本机是否存在 reviewSessionId 对应的 codex 会话文件;扩展端加载时计算, * 本工单引用的会话 id 里本机没有会话文件的那些(claude 四种 + codex 审查);
* 不持久化。codex resume 只依赖这个文件,与 worktree 无关。 * 扩展端加载时计算,不持久化。缺省或空 = 全部能 resume。
*/ */
reviewSessionFileExists?: boolean missingSessionIds?: string[]
/** /**
* Claude Code session id of the test conversation; manually started after * Claude Code session id of the test conversation; manually started after
* the PR is merged, resumable from the detail panel. * the PR is merged, resumable from the detail panel.