feat(vscode): 会话管理支持导入已 /rename 的命名会话

This commit is contained in:
2026-07-24 00:12:28 +08:00
parent 7c67d220db
commit dc522064ce
10 changed files with 438 additions and 9 deletions
+8
View File
@@ -556,6 +556,14 @@ export class KanbanWebviewPanel {
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
return
}
if (msg.type === 'managed-sessions/list-importable') {
void managedSessions.handleManagedSessionsListImportable(this)
return
}
if (msg.type === 'managed-sessions/import') {
void managedSessions.handleManagedSessionsImport(this, msg.sessionId, msg.name)
return
}
if (msg.type === 'pr-files/get') {
void prFiles.handleGetPrFiles(this, msg.issueNumber)
return
@@ -4,6 +4,7 @@ import { window, workspace } from 'vscode'
import { buildCcCommand } from '../../cc/ccCommand'
import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher'
import { logger } from '../../logging/logger'
import { listImportableNamedSessions } from '../../sessions/importableNamedSessions'
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
import { resolveProfilePath } from '../../cc/profiles'
@@ -270,3 +271,48 @@ export async function handleManagedSessionsDelete(panel: KanbanWebviewPanel, ses
panel.managedTerminals.delete(sessionId)
await pushManagedSessions(panel)
}
/**
* 列出当前工作区可导入的命名会话(jsonl 有 custom-title 且尚未在 managed store)。
*/
export async function handleManagedSessionsListImportable(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({ type: 'managed-sessions/importable', sessions: [] })
return
}
const sessions = await listImportableNamedSessions(workspaceRoot)
panel.postMessage({ type: 'managed-sessions/importable', sessions })
}
/**
* 把已 /rename 的会话登记进 managed store(不写 profilePath、不 resume)。
* id 已存在时幂等,仍推送全量列表。
*/
export async function handleManagedSessionsImport(
panel: KanbanWebviewPanel,
sessionId: string,
name: string,
): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const trimmedId = sessionId.trim()
const trimmedName = name.trim()
if (!trimmedId || !trimmedName)
return
const data = await readManagedSessions(workspaceRoot)
const existing = data.sessions.find(s => s.id === trimmedId)
if (!existing) {
data.sessions.push({
id: trimmedId,
name: trimmedName,
createdAt: Date.now(),
})
await writeManagedSessions(workspaceRoot, data)
}
await pushManagedSessions(panel)
}
+3
View File
@@ -104,6 +104,7 @@ export type ExtensionToWebview
| { type: 'issue/remove', issueNumber: number }
| { type: 'profiles/show', data: ProfilesData }
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
| { type: 'managed-sessions/importable', sessions: Array<{ id: string, name: string, mtimeMs: number }> }
| { type: 'pr-files/show', issueNumber: number, files: PrFile[], confirmed: string[], summaries: Record<string, string>, error?: string }
| { type: 'pr-file-diff/show', issueNumber: number, path: string, oldContent: string, newContent: string, oldLang?: string, newLang?: string, error?: string }
| { type: 'pr-file-summary/show', issueNumber: number, path: string, summary?: string, error?: string }
@@ -185,6 +186,8 @@ export type WebviewToExtension
| { type: 'managed-sessions/resume', sessionId: string }
| { type: 'managed-sessions/delete', sessionId: string }
| { type: 'managed-sessions/close-tab', sessionId: string }
| { type: 'managed-sessions/list-importable' }
| { type: 'managed-sessions/import', sessionId: string, name: string }
| { type: 'pr-files/get', issueNumber: number }
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
| { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string }
@@ -0,0 +1,124 @@
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
extractLastCustomTitle,
listImportableNamedSessions,
} from './importableNamedSessions'
import { writeManagedSessions } from './managedStore'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir)
rmSync(dir, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
describe('extractLastCustomTitle', () => {
it('多条 custom-title 时取最后一条', () => {
const text = [
JSON.stringify({ type: 'custom-title', customTitle: '旧名', sessionId: 'a' }),
JSON.stringify({ type: 'user', message: 'hi' }),
JSON.stringify({ type: 'custom-title', customTitle: '新名', sessionId: 'a' }),
'',
].join('\n')
expect(extractLastCustomTitle(text)).toBe('新名')
})
it('无 custom-title 返回 null', () => {
const text = [
JSON.stringify({ type: 'user', message: 'hi' }),
JSON.stringify({ type: 'assistant', message: 'yo' }),
].join('\n')
expect(extractLastCustomTitle(text)).toBeNull()
})
it('仅 ai-title 忽略,返回 null', () => {
const text = JSON.stringify({ type: 'ai-title', title: 'AI 起的名' })
expect(extractLastCustomTitle(text)).toBeNull()
})
it('customTitle 仅空白时忽略', () => {
const text = [
JSON.stringify({ type: 'custom-title', customTitle: ' ' }),
JSON.stringify({ type: 'custom-title', customTitle: '\t' }),
].join('\n')
expect(extractLastCustomTitle(text)).toBeNull()
})
it('trim 非空 customTitle', () => {
const text = JSON.stringify({ type: 'custom-title', customTitle: ' 有空格 ' })
expect(extractLastCustomTitle(text)).toBe('有空格')
})
it('非法 json 行跳过', () => {
const text = [
'not-json',
JSON.stringify({ type: 'custom-title', customTitle: 'ok' }),
'{broken',
].join('\n')
expect(extractLastCustomTitle(text)).toBe('ok')
})
})
describe('listImportableNamedSessions', () => {
it('过滤已在 managed store 的 id,按 mtime 降序', async () => {
const workspaceRoot = tempDir('importable-ws-')
const projectsDir = tempDir('importable-proj-')
const older = path.join(projectsDir, 'sess-old.jsonl')
const newer = path.join(projectsDir, 'sess-new.jsonl')
const managed = path.join(projectsDir, 'sess-managed.jsonl')
const unnamed = path.join(projectsDir, 'sess-unnamed.jsonl')
writeFileSync(older, `${JSON.stringify({ type: 'custom-title', customTitle: '旧会话' })}\n`)
writeFileSync(newer, `${JSON.stringify({ type: 'custom-title', customTitle: '新会话' })}\n`)
writeFileSync(managed, `${JSON.stringify({ type: 'custom-title', customTitle: '已登记' })}\n`)
writeFileSync(unnamed, `${JSON.stringify({ type: 'user', message: 'no title' })}\n`)
// 固定 mtimenewer 更晚
const oldMs = Date.now() - 60_000
const newMs = Date.now() - 10_000
utimesSync(older, new Date(oldMs), new Date(oldMs))
utimesSync(newer, new Date(newMs), new Date(newMs))
utimesSync(managed, new Date(newMs), new Date(newMs))
mkdirSync(path.join(workspaceRoot, '.spx'), { recursive: true })
await writeManagedSessions(workspaceRoot, {
sessions: [
{ id: 'sess-managed', name: '已登记', createdAt: 1 },
],
})
// 子目录内 jsonl 不应被扫到
const sub = path.join(projectsDir, 'subagents')
mkdirSync(sub)
writeFileSync(
path.join(sub, 'agent.jsonl'),
`${JSON.stringify({ type: 'custom-title', customTitle: '子代理' })}\n`,
)
const result = await listImportableNamedSessions(workspaceRoot, projectsDir)
expect(result.map(r => r.id)).toEqual(['sess-new', 'sess-old'])
expect(result[0]).toMatchObject({ id: 'sess-new', name: '新会话' })
expect(result[1]).toMatchObject({ id: 'sess-old', name: '旧会话' })
expect(result[0].mtimeMs).toBeGreaterThan(result[1].mtimeMs)
})
it('空目录返回 []', async () => {
const workspaceRoot = tempDir('importable-empty-ws-')
const projectsDir = tempDir('importable-empty-proj-')
const result = await listImportableNamedSessions(workspaceRoot, projectsDir)
expect(result).toEqual([])
})
})
@@ -0,0 +1,96 @@
/**
* 从 Claude projects 目录扫描已 /renamejsonl 含 custom-title)且尚未登记到
* managed store 的会话,供「导入」候选列表使用。
*/
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
import { projectsDirFor } from '../cc/sessionWatcher'
import { readManagedSessions } from './managedStore'
export interface ImportableNamedSession {
id: string
name: string
mtimeMs: number
}
/** 从 jsonl 文本提取最后一条 custom-titletrim 后非空),否则 null */
export function extractLastCustomTitle(jsonlText: string): string | null {
let last: string | null = null
const lines = jsonlText.split(/\r?\n/)
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed)
continue
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
}
catch {
continue
}
if (!parsed || typeof parsed !== 'object')
continue
const rec = parsed as { type?: unknown, customTitle?: unknown }
if (rec.type !== 'custom-title')
continue
if (typeof rec.customTitle !== 'string')
continue
const title = rec.customTitle.trim()
if (!title)
continue
last = title
}
return last
}
/**
* 列出可导入命名会话:扫 projectsDir 顶层 *.jsonl,过滤已在 managed 的 idmtime 降序。
* `projectsDir` 可选,便于测试注入;默认 `projectsDirFor(workspaceRoot)`。
*/
export async function listImportableNamedSessions(
workspaceRoot: string,
projectsDir?: string,
): Promise<ImportableNamedSession[]> {
const dir = projectsDir ?? projectsDirFor(workspaceRoot)
let entries: import('node:fs').Dirent[]
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
}
catch {
return []
}
const managed = await readManagedSessions(workspaceRoot)
const managedIds = new Set(managed.sessions.map(s => s.id))
const results: ImportableNamedSession[] = []
for (const ent of entries) {
if (!ent.isFile() || !ent.name.endsWith('.jsonl'))
continue
const id = ent.name.slice(0, -'.jsonl'.length)
if (!id || managedIds.has(id))
continue
const filePath = path.join(dir, ent.name)
let text: string
let mtimeMs: number
try {
const [raw, st] = await Promise.all([
fsp.readFile(filePath, 'utf8'),
fsp.stat(filePath),
])
text = raw
mtimeMs = st.mtimeMs
}
catch {
continue
}
const name = extractLastCustomTitle(text)
if (!name)
continue
results.push({ id, name, mtimeMs })
}
results.sort((a, b) => b.mtimeMs - a.mtimeMs)
return results
}