🐛 fix(vscode): 恢复实施会话改按显式角色命名,worktree 清理后不再误标为规划
Claude-Session: https://claude.ai/code/session_012YFEAU7RsuCdinUNMS9tzv
This commit is contained in:
1 parent
1fee0f2630
commit
e8963ac2a2
11 files changed
+112
-57
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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-实施')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
// 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
|
||||
// 实施会话住在 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}`,
|
||||
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(),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <id>` 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
|
||||
? [{
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user