🐛 fix(vscode): 别的机器上开的会话在本机没有会话文件时,会话 id 行置灰不可双击,不再 resume 报错
This commit is contained in:
1 parent
2b1e660cc8
commit
bf4661e364
20 files changed
+508
-234
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user