From 07b2bf75a081b024740d1f3f122e206912d89095 Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 25 Aug 2026 13:06:48 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=8F=AF=E6=8D=A2=20profile=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/panel/KanbanPanel.ts | 2 +- vscode/src/panel/handlers/managedSessions.ts | 41 ++++++----- vscode/src/panel/messages.ts | 2 +- .../src/sessions/managedResumeProfile.test.ts | 70 +++++++++++++++++++ vscode/src/sessions/managedResumeProfile.ts | 20 ++++++ .../webview-ui/src/components/BottomTabs.tsx | 2 +- .../src/components/ManagedSessionsPanel.tsx | 66 ++++++++++++++--- .../src/hooks/useManagedSessions.ts | 6 +- vscode/webview-ui/src/lib/messages.ts | 2 +- 9 files changed, 177 insertions(+), 34 deletions(-) create mode 100644 vscode/src/sessions/managedResumeProfile.test.ts create mode 100644 vscode/src/sessions/managedResumeProfile.ts diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index d1c04b7..8c163d4 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -556,7 +556,7 @@ export class KanbanWebviewPanel { return } if (msg.type === 'managed-sessions/resume') { - void managedSessions.handleManagedSessionsResume(this, msg.sessionId) + void managedSessions.handleManagedSessionsResume(this, msg.sessionId, msg.profilePath) return } if (msg.type === 'managed-sessions/delete') { diff --git a/vscode/src/panel/handlers/managedSessions.ts b/vscode/src/panel/handlers/managedSessions.ts index f1d6203..0a3d967 100644 --- a/vscode/src/panel/handlers/managedSessions.ts +++ b/vscode/src/panel/handlers/managedSessions.ts @@ -5,6 +5,7 @@ import { buildCcCommand } from '../../cc/ccCommand' import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher' import { logger } from '../../logging/logger' import { listImportableNamedSessions } from '../../sessions/importableNamedSessions' +import { decideManagedResumeProfile } from '../../sessions/managedResumeProfile' import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore' import { inferProfilePathForSession } from '../../sessions/sessionProfileInference' import { resolveProfilePath } from '../../cc/profiles' @@ -225,7 +226,7 @@ export async function handleManagedSessionsRename(panel: KanbanWebviewPanel, ses * 恢复一个受管理会话:在新终端 tab 里跑 `claude ... --resume `, * cwd = workspaceRoot。in-flight 锁防止重复点击重复 createTerminal。 */ -export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, sessionId: string): Promise { +export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string): Promise { const lockKey = `managed:${sessionId}` if (panel.resumeInFlight.has(lockKey)) { logger.add({ @@ -244,29 +245,35 @@ export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, ses return } + const data = await readManagedSessions(workspaceRoot) + const target = data.sessions.find(s => s.id === sessionId) + + const override = profilePath?.trim() || undefined + let inferred: string | null = null + if (!override && (!target?.profilePath || target.profilePath.trim() === '') && target) { + inferred = await inferProfilePathForSession(sessionId, projectsDirFor(workspaceRoot)) + } + + const decided = decideManagedResumeProfile({ + storedProfile: target?.profilePath, + overrideProfile: profilePath, + inferredProfile: inferred, + }) + if (decided.persist && target) { + target.profilePath = decided.persist + await writeManagedSessions(workspaceRoot, data) + } + // 已有该会话的存活终端,直接聚焦、不再开新的(防重复)。 const existing = panel.managedTerminals.get(sessionId) if (existing) { + if (decided.persist) + await pushManagedSessions(panel) existing.show(false) return } - const data = await readManagedSessions(workspaceRoot) - const target = data.sessions.find(s => s.id === sessionId) - - // store 缺 profilePath 时从 transcript 推断并回写,避免 resume 落到 default profile。 - let storedProfile = target?.profilePath - if ((!storedProfile || storedProfile.trim() === '') && target) { - const inferred = await inferProfilePathForSession(sessionId, projectsDirFor(workspaceRoot)) - if (inferred) { - storedProfile = inferred - target.profilePath = inferred - await writeManagedSessions(workspaceRoot, data) - } - } - const effectiveProfilePath = resolveProfilePath( - storedProfile && storedProfile.trim() !== '' ? storedProfile : undefined, - ) + const effectiveProfilePath = resolveProfilePath(decided.effective) if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( `resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 87ba8ce..4dbc360 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -191,7 +191,7 @@ export type WebviewToExtension | { type: 'managed-sessions/get' } | { type: 'managed-sessions/create', profilePath: string, name?: string, prompt?: string } | { type: 'managed-sessions/rename', sessionId: string, name: string } - | { type: 'managed-sessions/resume', sessionId: string } + | { type: 'managed-sessions/resume', sessionId: string, profilePath?: string } | { type: 'managed-sessions/delete', sessionId: string } | { type: 'managed-sessions/close-tab', sessionId: string } | { type: 'managed-sessions/list-importable' } diff --git a/vscode/src/sessions/managedResumeProfile.test.ts b/vscode/src/sessions/managedResumeProfile.test.ts new file mode 100644 index 0000000..56aae94 --- /dev/null +++ b/vscode/src/sessions/managedResumeProfile.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { decideManagedResumeProfile } from './managedResumeProfile' + +describe('decideManagedResumeProfile', () => { + it('override 非空时用 override,与 stored 不同则写回', () => { + expect(decideManagedResumeProfile({ + storedProfile: '/profiles/grok-4.5.json', + overrideProfile: '/profiles/opus.json', + inferredProfile: '/profiles/from-jsonl.json', + })).toEqual({ + effective: '/profiles/opus.json', + persist: '/profiles/opus.json', + }) + }) + + it('override 与 stored trim 后相同则不写回,且忽略 inferred', () => { + expect(decideManagedResumeProfile({ + storedProfile: ' /profiles/grok-4.5.json ', + overrideProfile: '/profiles/grok-4.5.json', + inferredProfile: '/profiles/from-jsonl.json', + })).toEqual({ + effective: '/profiles/grok-4.5.json', + persist: undefined, + }) + }) + + it('空白 override 视为未提供,回落到 stored', () => { + expect(decideManagedResumeProfile({ + storedProfile: '/profiles/grok-4.5.json', + overrideProfile: ' ', + inferredProfile: '/profiles/from-jsonl.json', + })).toEqual({ + effective: '/profiles/grok-4.5.json', + persist: undefined, + }) + }) + + it('无 override 时用 stored,不写回也不用 inferred', () => { + expect(decideManagedResumeProfile({ + storedProfile: '/profiles/grok-4.5.json', + overrideProfile: undefined, + inferredProfile: '/profiles/from-jsonl.json', + })).toEqual({ + effective: '/profiles/grok-4.5.json', + persist: undefined, + }) + }) + + it('override 与 stored 都空时用 inferred 并写回', () => { + expect(decideManagedResumeProfile({ + storedProfile: ' ', + overrideProfile: undefined, + inferredProfile: '/profiles/from-jsonl.json', + })).toEqual({ + effective: '/profiles/from-jsonl.json', + persist: '/profiles/from-jsonl.json', + }) + }) + + it('三者都空则 effective 与 persist 都为 undefined', () => { + expect(decideManagedResumeProfile({ + storedProfile: undefined, + overrideProfile: '', + inferredProfile: null, + })).toEqual({ + effective: undefined, + persist: undefined, + }) + }) +}) diff --git a/vscode/src/sessions/managedResumeProfile.ts b/vscode/src/sessions/managedResumeProfile.ts new file mode 100644 index 0000000..3f2ad0c --- /dev/null +++ b/vscode/src/sessions/managedResumeProfile.ts @@ -0,0 +1,20 @@ +export function decideManagedResumeProfile(input: { + storedProfile: string | undefined + overrideProfile: string | undefined + inferredProfile: string | null +}): { effective: string | undefined, persist: string | undefined } { + const override = input.overrideProfile?.trim() || undefined + const stored = input.storedProfile?.trim() || undefined + + if (override) { // ① 行内覆盖:与 store 不同才写回;忽略 jsonl 推断 + return { + effective: override, + persist: override !== stored ? override : undefined, + } + } + if (stored) // ② store 已有 → 沿用,不写回 + return { effective: stored, persist: undefined } + if (input.inferredProfile) // ③ 缺 profile 才用 jsonl 回填并写回 + return { effective: input.inferredProfile, persist: input.inferredProfile } + return { effective: undefined, persist: undefined } +} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index 5a449ac..f7c7648 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -60,7 +60,7 @@ interface BottomTabsProps { profiles: ClaudeProfile[] onManagedSessionCreate: (profilePath: string, name?: string, prompt?: string) => void onManagedSessionRename: (id: string, name: string) => void - onManagedSessionResume: (id: string) => void + onManagedSessionResume: (id: string, profilePath?: string) => void onManagedSessionDelete: (id: string) => void onManagedSessionCloseTab: (id: string) => void importableSessions: Array<{ id: string, name: string, mtimeMs: number }> diff --git a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx index 4e20efb..aead87c 100644 --- a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx +++ b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx @@ -3,8 +3,8 @@ * * - 上方表单:选 profile(下拉,复用 useIssues 的 ClaudeProfile 列表)+ 首个提示词 * (可选,多行)+「创建」按钮 → 主进程开一个 cc 终端 tab 并捕获会话。 - * - 下方列表:每行显示名字(双击行内改名,Enter 提交、Esc 取消)、profile 文件名、 - * 创建时间;点击行(非编辑区)→ 主进程新终端 resume;行尾删除按钮(仅移除记录)。 + * - 下方列表:每行显示名字(双击行内改名,Enter 提交、Esc 取消)、行内可改 profile、 + * 创建时间;点行用该 profile resume 并写回;行尾删除按钮(仅移除记录)。 */ import type { ClaudeProfile } from '../hooks/useIssues' @@ -17,7 +17,7 @@ interface ManagedSessionsPanelProps { profiles: ClaudeProfile[] onCreate: (profilePath: string, name?: string, prompt?: string) => void onRename: (id: string, name: string) => void - onResume: (id: string) => void + onResume: (id: string, profilePath?: string) => void onDelete: (id: string) => void onCloseTab: (id: string) => void importableSessions: Array<{ id: string, name: string, mtimeMs: number }> @@ -33,6 +33,16 @@ function basename(p?: string): string { return idx >= 0 ? p.slice(idx + 1) : p } +function matchStoredProfile(stored: string | undefined, profiles: ClaudeProfile[]): string { + if (!stored) + return '' + if (profiles.some(p => p.path === stored)) + return stored + const storedBase = basename(stored) + const fuzzy = profiles.find(p => basename(p.path) === storedBase || p.name === storedBase) + return fuzzy?.path ?? stored +} + function formatTime(ts: number): string { if (!ts) return '' @@ -246,6 +256,7 @@ export function ManagedSessionsPanel({ void - onResume: (id: string) => void + onResume: (id: string, profilePath?: string) => void onDelete: (id: string) => void onCloseTab: (id: string) => void } -function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: SessionRowProps) { +function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTab }: SessionRowProps) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(session.name) + const [userPicked, setUserPicked] = useState(undefined) const inputRef = useRef(null) + const pickedProfile = userPicked ?? matchStoredProfile(session.profilePath, profiles) useLayoutEffect(() => { if (!editing) @@ -282,6 +296,10 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi el.select() }, [editing]) + useEffect(() => { + setUserPicked(undefined) + }, [session.profilePath]) + const beginEdit = useCallback((): void => { setDraft(session.name) setEditing(true) @@ -299,8 +317,12 @@ function SessionRow({ session, onRename, onResume, onDelete, onCloseTab }: Sessi setDraft(session.name) }, [session.name]) - const profileName = basename(session.profilePath) + const resume = useCallback((): void => { + onResume(session.id, pickedProfile || session.profilePath) + }, [onResume, session.id, session.profilePath, pickedProfile]) + const created = formatTime(session.createdAt) + const storedMissing = !profiles.some(p => p.path === pickedProfile) return (