🐛 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

+10 -2
View File
@@ -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
@@ -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<Map<string, number>>(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: <Play className="size-3.5" />,
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 {
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 <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: (() => {
(() => {
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
// 给出不同的提示文案。
// 本机没有会话文件时只读且不给 ▶:重新实施会先删远程 feature 分支,
// 会把别的机器上的实施进度一并抹掉。
const secondaryActions: RowAction[] = (() => {
if (issue?.implementTabOpen) {
return [{
icon: <X className="size-3.5" />,
@@ -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: <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',
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: <Terminal className="size-3.5" />,
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: <Play className="size-3.5" />,
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 {
key: 'prototypeSessionId',
label: '原型会话id',
+1 -1
View File
@@ -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<string, 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。 */
| { type: 'session/tab-settled', sessionId: string }
| { 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). */
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.