From e8963ac2a243a1398b5adf1dc074832c358c0595 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 9 Sep 2026 10:41:04 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=AE=9E=E6=96=BD=E4=BC=9A=E8=AF=9D=E6=94=B9=E6=8C=89?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E8=A7=92=E8=89=B2=E5=91=BD=E5=90=8D=EF=BC=8C?= =?UTF-8?q?worktree=20=E6=B8=85=E7=90=86=E5=90=8E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AF=AF=E6=A0=87=E4=B8=BA=E8=A7=84=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_012YFEAU7RsuCdinUNMS9tzv --- vscode/package.json | 2 +- vscode/src/panel/KanbanPanel.ts | 2 +- .../src/panel/handlers/resumeTarget.test.ts | 37 +++++++++ vscode/src/panel/handlers/resumeTarget.ts | 39 ++++++++++ vscode/src/panel/handlers/sessions.ts | 78 +++++++------------ vscode/src/panel/messages.ts | 2 +- vscode/webview-ui/src/App.tsx | 2 +- .../webview-ui/src/components/BottomTabs.tsx | 2 +- .../src/components/IssueDetailPanel.tsx | 6 +- vscode/webview-ui/src/hooks/useIssues.ts | 5 +- vscode/webview-ui/src/lib/messages.ts | 2 +- 11 files changed, 116 insertions(+), 61 deletions(-) create mode 100644 vscode/src/panel/handlers/resumeTarget.test.ts create mode 100644 vscode/src/panel/handlers/resumeTarget.ts diff --git a/vscode/package.json b/vscode/package.json index f3955e8..da6f66f 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.123", + "version": "0.2.124", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index b24ca52..62b8975 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -395,7 +395,7 @@ export class KanbanWebviewPanel { return } if (msg.type === 'session/resume') { - void sessions.handleResumeSession(this, msg.sessionId, msg.profilePath, msg.cwd, msg.issueNumber) + void sessions.handleResumeSession(this, msg.sessionId, msg.kind, msg.profilePath, msg.cwd, msg.issueNumber) return } if (msg.type === 'session/focus') { diff --git a/vscode/src/panel/handlers/resumeTarget.test.ts b/vscode/src/panel/handlers/resumeTarget.test.ts new file mode 100644 index 0000000..3f550d6 --- /dev/null +++ b/vscode/src/panel/handlers/resumeTarget.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { planResumeCwd, resumeTerminalName } from './resumeTarget' + +const ROOT = '/repo' +const WT = '/repo-worktrees/feature-x' + +describe('resumeTerminalName', () => { + it('实施会话 → issue-N-实施', () => { + expect(resumeTerminalName('implement', 'abcdef1234', 398)).toBe('issue-398-实施') + }) + it('规划会话 → issue-N-规划', () => { + expect(resumeTerminalName('brainstorm', 'abcdef1234', 398)).toBe('issue-398-规划') + }) + it('没有工单号 → 按会话 id 前 8 位命名', () => { + expect(resumeTerminalName('implement', 'abcdef1234', undefined)).toBe('Claude · abcdef12') + }) +}) + +describe('planResumeCwd', () => { + it('规划会话永远在工作区根目录', () => { + expect(planResumeCwd({ kind: 'brainstorm', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: true })) + .toEqual({ cwd: ROOT, worktreeMissing: false }) + }) + it('实施会话 worktree 在 → cwd 用 worktree', () => { + expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: true })) + .toEqual({ cwd: WT, worktreeMissing: false }) + }) + it('实施会话 worktree 记了但磁盘上没了 → 退回根目录并标记缺失', () => { + expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: WT, worktreeExists: false })) + .toEqual({ cwd: ROOT, worktreeMissing: true }) + }) + it('实施会话工单里没记 worktreePath(PR 合并后清掉)→ 同样标记缺失,角色不变', () => { + expect(planResumeCwd({ kind: 'implement', workspaceRoot: ROOT, worktreeAbs: undefined, worktreeExists: false })) + .toEqual({ cwd: ROOT, worktreeMissing: true }) + expect(resumeTerminalName('implement', 'abcdef1234', 398)).toBe('issue-398-实施') + }) +}) diff --git a/vscode/src/panel/handlers/resumeTarget.ts b/vscode/src/panel/handlers/resumeTarget.ts new file mode 100644 index 0000000..d5175b9 --- /dev/null +++ b/vscode/src/panel/handlers/resumeTarget.ts @@ -0,0 +1,39 @@ +/** + * 实施 / 规划会话 resume 的落点决策。纯函数,不碰 vscode API,便于单测。 + * + * 角色由调用方明确传入,不从「有没有 cwd」反推:worktree 被清理后 + * 工单里的 worktreePath 会清空,靠 cwd 推断会把实施会话误判成规划。 + */ +export type ResumeKind = 'implement' | 'brainstorm' + +export function resumeSessionRole(kind: ResumeKind): '实施' | '规划' { + return kind === 'implement' ? '实施' : '规划' +} + +export function resumeTerminalName(kind: ResumeKind, sessionId: string, issueNumber?: number): string { + return issueNumber !== undefined + ? `issue-${issueNumber}-${resumeSessionRole(kind)}` + : `Claude · ${sessionId.slice(0, 8)}` +} + +export interface ResumeCwdInput { + kind: ResumeKind + workspaceRoot: string + /** 已解析成绝对路径的 worktree;工单没记就 undefined */ + worktreeAbs?: string + worktreeExists: boolean +} + +export interface ResumeCwdPlan { + cwd: string + /** 实施会话但 worktree 已不在磁盘上(含工单里没记 worktreePath 的情况) */ + worktreeMissing: boolean +} + +export function planResumeCwd(input: ResumeCwdInput): ResumeCwdPlan { + if (input.kind !== 'implement') + return { cwd: input.workspaceRoot, worktreeMissing: false } + if (input.worktreeAbs && input.worktreeExists) + return { cwd: input.worktreeAbs, worktreeMissing: false } + return { cwd: input.workspaceRoot, worktreeMissing: true } +} diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 6e5ae11..42c1d04 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -1,5 +1,6 @@ import type { Terminal } from 'vscode' import type { KanbanWebviewPanel } from '../KanbanPanel' +import type { ResumeKind } from './resumeTarget' import { execFile } from 'node:child_process' import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' @@ -25,13 +26,10 @@ import { decideAutoReviewPath } from '../autoReviewDecision' import { pickBrainstormContinuePath } from '../brainstormContinue' import { makeNonce } from '../KanbanPanel' import { cleanupFeatureBranch } from './branchCleanup' +import { planResumeCwd, resumeTerminalName } from './resumeTarget' import { submitToSessionTerminal } from './terminals' -export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise { - // kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造 - // resumeInFlight 锁 key)。relCwd 在则是实施 resume,否则视为规划/讨论。 - const sessionKind: 'implement' | 'brainstorm' = relCwd ? 'implement' : 'brainstorm' - +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 为空时 // (legacy session)跳过锁,直接走原路径。 @@ -91,45 +89,25 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: return } const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - // Implementation sessions live in a worktree; `relCwd` may be absolute / - // `~/...` / workspace-relative — must go through resolveWorktreePath - // (path.join(workspaceRoot, abs) would mangle absolute paths). - // If the worktree has since been cleaned up (typical: drop card to - // "完成" → auto `git worktree remove`), fall back to the workspace - // root instead of pointing the terminal at a missing directory. - let effectiveCwd: string | undefined = workspaceRoot - let cwdFallback = false - if (relCwd && workspaceRoot) { - const resolved = resolveWorktreePath(relCwd, workspaceRoot) - if (fs.existsSync(resolved)) { - effectiveCwd = resolved - } - else { - cwdFallback = true - effectiveCwd = workspaceRoot - logger.add({ - level: 'warn', - source: 'panel', - message: `worktree 不存在,退回工作区根目录 (#${issueNumber ?? '?'}): ${relCwd}`, - }) - } + // 实施会话住在 worktree 里;relCwd 可能是绝对路径 / `~/...` / 工作区相对 + // 路径,统一走 resolveWorktreePath。worktree 已被清理(典型:卡片拖到 + // 「完成」→ 自动 `git worktree remove`,工单里 worktreePath 随之清空) + // 时退回工作区根目录;角色只看 sessionKind,不受 cwd 有无影响。 + const worktreeAbs = relCwd && workspaceRoot ? resolveWorktreePath(relCwd, workspaceRoot) : undefined + const plan = workspaceRoot + ? planResumeCwd({ kind: sessionKind, workspaceRoot, worktreeAbs, worktreeExists: !!worktreeAbs && fs.existsSync(worktreeAbs) }) + : undefined + const effectiveCwd = plan?.cwd + const worktreeMissing = plan?.worktreeMissing ?? false + if (worktreeMissing) { + logger.add({ + level: 'warn', + source: 'panel', + message: `worktree 不存在,退回工作区根目录 (#${issueNumber ?? '?'}): ${relCwd ?? '(工单未记录 worktreePath)'}`, + }) } - // Open the terminal as an editor tab beside the kanban (not in the - // bottom panel), so the user can see both side-by-side. `Beside` opens - // in a new editor group when needed. - // Name reflects which of the three session roles this is: - // relCwd present → 实施 session (resumed inside the worktree) - // relCwd absent → 规划 / discussion session (workspace root) - // Fall back to the legacy id-suffixed name if we somehow lack issueNumber. - const sessionRole = relCwd ? '实施' : '规划' - // 注:sessionKind 已在方法入口处判定(用于 resumeInFlight 锁 key)。 - // 原来两条 trackSessionTerminal 路径都硬写 'brainstorm',导致实施 resume - // 后 terminalOrigin map 里 kind 错,onDidCloseTerminal 里的 - // `kind === 'implement'` 判断永远不命中,impl-tab-post-close 钩子(关 - // pycharm)不会执行。 - const terminalName = issueNumber !== undefined - ? `issue-${issueNumber}-${sessionRole}` - : `Claude · ${sessionId.slice(0, 8)}` + // 终端开成编辑区 tab(不在底部面板),和看板并排。 + const terminalName = resumeTerminalName(sessionKind, sessionId, issueNumber) // Scan live terminals before creating a new one — survives panel // reload / webview rebuild where `this.terminals` got wiped but the // tab is still open. Refresh the Map too so subsequent lookups (and @@ -142,12 +120,12 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: existingByName.show(false) return } - // 实施 resume 走新建 terminal 路径时(且 worktree 还在)需触发 - // impl-tab-pre-create 钩子(启动 pycharm 等)。existingByName fast path - // 不调钩子——pycharm 应该已经在跑,重复启会冲突;cwdFallback=true 时 - // worktree 已清理,pycharm 不该指向 workspace root;legacy session - // (issueNumber === undefined) 钩子签名要 issueNumber,也跳过。 - if (sessionRole === '实施' && !cwdFallback && issueNumber !== undefined && workspaceRoot) { + // 实施 resume 走新建 terminal 路径且 worktree 还在时触发 impl-tab-pre-create + // 钩子(启动 pycharm 等)。existingByName fast path 不调——pycharm 应该已 + // 在跑,重复启会冲突;worktree 没了也不调——钩子拿 WORKTREE_PATH 开 + // pycharm,指向工作区根目录会把主仓库窗口拉起来;legacy session + // (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。 + if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) { const settingsForHook = getSettings(panel.context) // 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里 // BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。 @@ -205,7 +183,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: }) // Worktree 已清理时给用户一个明确提示,避免他们误以为 cc 在原 // worktree 路径里跑。toast 上 dismissOnTimer 5s 自动消失。 - if (cwdFallback) { + if (worktreeMissing) { panel.postMessage({ type: 'toast/show', id: makeNonce(), diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index a30d437..33c9c4a 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -180,7 +180,7 @@ export type WebviewToExtension | { type: 'youtrack/import' } | { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string } | { type: 'toast/open-url', url: string } - | { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number } + | { type: 'session/resume', sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number } | { type: 'session/focus', issueNumber: number } | { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string } diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 4e74f99..74601b6 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -225,7 +225,7 @@ export function App() { const profileForResume = isImpl ? selectedIssue?.profilePath : selectedIssue?.brainstormProfilePath - resumeSession(sid, profileForResume, cwd, selectedIssue?.number) + resumeSession(sid, isImpl ? 'implement' : 'brainstorm', profileForResume, cwd, selectedIssue?.number) } return } diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index 19ab5bc..246e055 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -28,7 +28,7 @@ interface BottomTabsProps { allIssues: Issue[] globalAutoReview: boolean onOpenInBrowser: (url: string) => void - onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void + onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void onStartTestSession: (issueNumber: number) => void onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 47ce25f..073bde1 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -35,7 +35,7 @@ interface IssueDetailPanelProps { /** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */ globalAutoReview: boolean onOpenInBrowser: (url: string) => void - onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void + onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void /** * Open a new terminal that runs `codex resume ` for the auto-review * conversation associated with this issue. @@ -243,7 +243,7 @@ export function IssueDetailPanel({ return if (!throttleBySessionId(v)) return - onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number) + onResumeSession(v, 'brainstorm', issue?.brainstormProfilePath, undefined, issue?.number) }, secondaryActions, } @@ -265,7 +265,7 @@ export function IssueDetailPanel({ return if (!throttleBySessionId(v)) return - onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number) + onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number) }, secondaryActions: issue?.implementTabOpen ? [{ diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 97f9161..5551f5f 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -170,7 +170,7 @@ export interface UseIssuesResult { createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void dismissToast: (id: string) => void openUrl: (url: string) => void - resumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void + resumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void startTestSession: (issueNumber: number) => void resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void @@ -454,11 +454,12 @@ export function useIssues(): UseIssuesResult { const resumeSession = useCallback(( sessionId: string, + kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number, ): void => { - postMessage({ type: 'session/resume', sessionId, profilePath, cwd, issueNumber }) + postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber }) }, []) const focusSession = useCallback((issueNumber: number): void => { diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 5feab03..91c3e2b 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -211,7 +211,7 @@ export type WebviewToExtension | { type: 'youtrack/import' } | { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string } | { type: 'toast/open-url', url: string } - | { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number } + | { type: 'session/resume', sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number } | { type: 'session/focus', issueNumber: number } | { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }