✨ feat(vscode): 会话管理支持导入已 /rename 的命名会话
This commit is contained in:
@@ -556,6 +556,14 @@ export class KanbanWebviewPanel {
|
|||||||
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
|
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
|
||||||
return
|
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') {
|
if (msg.type === 'pr-files/get') {
|
||||||
void prFiles.handleGetPrFiles(this, msg.issueNumber)
|
void prFiles.handleGetPrFiles(this, msg.issueNumber)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { window, workspace } from 'vscode'
|
|||||||
import { buildCcCommand } from '../../cc/ccCommand'
|
import { buildCcCommand } from '../../cc/ccCommand'
|
||||||
import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher'
|
import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
|
import { listImportableNamedSessions } from '../../sessions/importableNamedSessions'
|
||||||
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
||||||
import { resolveProfilePath } from '../../cc/profiles'
|
import { resolveProfilePath } from '../../cc/profiles'
|
||||||
|
|
||||||
@@ -270,3 +271,48 @@ export async function handleManagedSessionsDelete(panel: KanbanWebviewPanel, ses
|
|||||||
panel.managedTerminals.delete(sessionId)
|
panel.managedTerminals.delete(sessionId)
|
||||||
await pushManagedSessions(panel)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issue/remove', issueNumber: number }
|
| { type: 'issue/remove', issueNumber: number }
|
||||||
| { type: 'profiles/show', data: ProfilesData }
|
| { type: 'profiles/show', data: ProfilesData }
|
||||||
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
|
| { 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-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-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 }
|
| { 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/resume', sessionId: string }
|
||||||
| { type: 'managed-sessions/delete', sessionId: string }
|
| { type: 'managed-sessions/delete', sessionId: string }
|
||||||
| { type: 'managed-sessions/close-tab', 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-files/get', issueNumber: number }
|
||||||
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
||||||
| { type: 'pr-file-summary/generate', 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`)
|
||||||
|
|
||||||
|
// 固定 mtime:newer 更晚
|
||||||
|
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 目录扫描已 /rename(jsonl 含 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-title(trim 后非空),否则 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 的 id,mtime 降序。
|
||||||
|
* `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
|
||||||
|
}
|
||||||
@@ -83,6 +83,10 @@ export function App() {
|
|||||||
resumeManagedSession,
|
resumeManagedSession,
|
||||||
deleteManagedSession,
|
deleteManagedSession,
|
||||||
closeManagedSessionTab,
|
closeManagedSessionTab,
|
||||||
|
listImportableNamedSessions,
|
||||||
|
importNamedSession,
|
||||||
|
importableSessions,
|
||||||
|
importableLoading,
|
||||||
} = useManagedSessions()
|
} = useManagedSessions()
|
||||||
const [showNewIssueModal, setShowNewIssueModal] = useState(false)
|
const [showNewIssueModal, setShowNewIssueModal] = useState(false)
|
||||||
const [showLogs, setShowLogs] = useState(false)
|
const [showLogs, setShowLogs] = useState(false)
|
||||||
@@ -373,6 +377,10 @@ export function App() {
|
|||||||
onManagedSessionResume={resumeManagedSession}
|
onManagedSessionResume={resumeManagedSession}
|
||||||
onManagedSessionDelete={deleteManagedSession}
|
onManagedSessionDelete={deleteManagedSession}
|
||||||
onManagedSessionCloseTab={closeManagedSessionTab}
|
onManagedSessionCloseTab={closeManagedSessionTab}
|
||||||
|
importableSessions={importableSessions}
|
||||||
|
importableLoading={importableLoading}
|
||||||
|
onListImportableSessions={listImportableNamedSessions}
|
||||||
|
onImportNamedSession={importNamedSession}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ interface BottomTabsProps {
|
|||||||
onManagedSessionResume: (id: string) => void
|
onManagedSessionResume: (id: string) => void
|
||||||
onManagedSessionDelete: (id: string) => void
|
onManagedSessionDelete: (id: string) => void
|
||||||
onManagedSessionCloseTab: (id: string) => void
|
onManagedSessionCloseTab: (id: string) => void
|
||||||
|
importableSessions: Array<{ id: string, name: string, mtimeMs: number }>
|
||||||
|
importableLoading: boolean
|
||||||
|
onListImportableSessions: () => void
|
||||||
|
onImportNamedSession: (sessionId: string, name: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomTabs(props: BottomTabsProps) {
|
export function BottomTabs(props: BottomTabsProps) {
|
||||||
@@ -143,6 +147,10 @@ export function BottomTabs(props: BottomTabsProps) {
|
|||||||
onResume={props.onManagedSessionResume}
|
onResume={props.onManagedSessionResume}
|
||||||
onDelete={props.onManagedSessionDelete}
|
onDelete={props.onManagedSessionDelete}
|
||||||
onCloseTab={props.onManagedSessionCloseTab}
|
onCloseTab={props.onManagedSessionCloseTab}
|
||||||
|
importableSessions={props.importableSessions}
|
||||||
|
importableLoading={props.importableLoading}
|
||||||
|
onListImportable={props.onListImportableSessions}
|
||||||
|
onImport={props.onImportNamedSession}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 'changes' && (
|
{tab === 'changes' && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||||
import type { ManagedSession, ManagedSessionsData } from '../types'
|
import type { ManagedSession, ManagedSessionsData } from '../types'
|
||||||
import { Plus, Terminal, Trash2, X } from 'lucide-react'
|
import { Download, Plus, Terminal, Trash2, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface ManagedSessionsPanelProps {
|
interface ManagedSessionsPanelProps {
|
||||||
@@ -20,6 +20,10 @@ interface ManagedSessionsPanelProps {
|
|||||||
onResume: (id: string) => void
|
onResume: (id: string) => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
onCloseTab: (id: string) => void
|
onCloseTab: (id: string) => void
|
||||||
|
importableSessions: Array<{ id: string, name: string, mtimeMs: number }>
|
||||||
|
importableLoading: boolean
|
||||||
|
onListImportable: () => void
|
||||||
|
onImport: (sessionId: string, name: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function basename(p?: string): string {
|
function basename(p?: string): string {
|
||||||
@@ -45,10 +49,24 @@ function formatTime(ts: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onResume, onDelete, onCloseTab }: ManagedSessionsPanelProps) {
|
export function ManagedSessionsPanel({
|
||||||
|
data,
|
||||||
|
profiles,
|
||||||
|
onCreate,
|
||||||
|
onRename,
|
||||||
|
onResume,
|
||||||
|
onDelete,
|
||||||
|
onCloseTab,
|
||||||
|
importableSessions,
|
||||||
|
importableLoading,
|
||||||
|
onListImportable,
|
||||||
|
onImport,
|
||||||
|
}: ManagedSessionsPanelProps) {
|
||||||
const [selectedProfile, setSelectedProfile] = useState<string>('')
|
const [selectedProfile, setSelectedProfile] = useState<string>('')
|
||||||
const [name, setName] = useState<string>('')
|
const [name, setName] = useState<string>('')
|
||||||
const [prompt, setPrompt] = useState<string>('')
|
const [prompt, setPrompt] = useState<string>('')
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const importWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
// 默认选中官方 profile(profiles 异步到达后;用户手动改选后不覆盖)。
|
// 默认选中官方 profile(profiles 异步到达后;用户手动改选后不覆盖)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,6 +76,29 @@ export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onRes
|
|||||||
setSelectedProfile((official ?? profiles[0]).path)
|
setSelectedProfile((official ?? profiles[0]).path)
|
||||||
}, [profiles, selectedProfile])
|
}, [profiles, selectedProfile])
|
||||||
|
|
||||||
|
// 弹层:外点击 / Esc 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importOpen)
|
||||||
|
return
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape')
|
||||||
|
setImportOpen(false)
|
||||||
|
}
|
||||||
|
function onPointer(e: MouseEvent): void {
|
||||||
|
const el = importWrapRef.current
|
||||||
|
if (!el)
|
||||||
|
return
|
||||||
|
if (e.target instanceof Node && !el.contains(e.target))
|
||||||
|
setImportOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
window.addEventListener('mousedown', onPointer)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
window.removeEventListener('mousedown', onPointer)
|
||||||
|
}
|
||||||
|
}, [importOpen])
|
||||||
|
|
||||||
const sessions = useMemo(
|
const sessions = useMemo(
|
||||||
() => [...data.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
() => [...data.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
||||||
[data.sessions],
|
[data.sessions],
|
||||||
@@ -75,6 +116,16 @@ export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onRes
|
|||||||
setPrompt('')
|
setPrompt('')
|
||||||
}, [canCreate, name, prompt, selectedProfile, onCreate])
|
}, [canCreate, name, prompt, selectedProfile, onCreate])
|
||||||
|
|
||||||
|
const openImport = useCallback((): void => {
|
||||||
|
setImportOpen(true)
|
||||||
|
onListImportable()
|
||||||
|
}, [onListImportable])
|
||||||
|
|
||||||
|
const pickImport = useCallback((sessionId: string, sessionName: string): void => {
|
||||||
|
onImport(sessionId, sessionName)
|
||||||
|
setImportOpen(false)
|
||||||
|
}, [onImport])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
|
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--vscode-editor-background)] text-[var(--vscode-foreground)]">
|
||||||
{/* 上方表单 */}
|
{/* 上方表单 */}
|
||||||
@@ -97,13 +148,65 @@ export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onRes
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">名字</span>
|
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">名字</span>
|
||||||
<input
|
<div className="relative min-w-0 flex-1" ref={importWrapRef}>
|
||||||
type="text"
|
<div className="flex items-center gap-1">
|
||||||
value={name}
|
<input
|
||||||
onChange={e => setName(e.target.value)}
|
type="text"
|
||||||
placeholder="会话名字(可选)"
|
value={name}
|
||||||
className="min-w-0 flex-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none placeholder:text-[var(--vscode-input-placeholderForeground)] focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
|
onChange={e => setName(e.target.value)}
|
||||||
/>
|
placeholder="会话名字(可选)"
|
||||||
|
className="min-w-0 flex-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none placeholder:text-[var(--vscode-input-placeholderForeground)] focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openImport}
|
||||||
|
title="导入已 /rename 的命名会话"
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-button-secondaryBackground,var(--vscode-input-background))] px-2 py-1 text-xs text-[var(--vscode-button-secondaryForeground,var(--vscode-foreground))] hover:bg-[var(--vscode-button-secondaryHoverBackground,var(--vscode-toolbar-hoverBackground))]"
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
<span>导入</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{importOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-full z-20 mt-1 max-h-48 overflow-auto rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editorWidget-background,var(--vscode-editor-background))] shadow-md"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="可导入的命名会话"
|
||||||
|
>
|
||||||
|
{importableLoading
|
||||||
|
? (
|
||||||
|
<div className="px-2 py-3 text-center text-xs text-[var(--vscode-descriptionForeground)]">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: importableSessions.length === 0
|
||||||
|
? (
|
||||||
|
<div className="px-2 py-3 text-center text-xs text-[var(--vscode-descriptionForeground)]">
|
||||||
|
没有可导入的命名会话
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<ul className="flex flex-col py-1">
|
||||||
|
{importableSessions.map(s => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
onClick={() => pickImport(s.id, s.name)}
|
||||||
|
className="flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left hover:bg-[var(--vscode-list-hoverBackground)]"
|
||||||
|
>
|
||||||
|
<span className="truncate text-xs">{s.name}</span>
|
||||||
|
<span className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||||
|
{formatTime(s.mtimeMs)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="w-12 shrink-0 pt-1 text-xs text-[var(--vscode-descriptionForeground)]">提示词</span>
|
<span className="w-12 shrink-0 pt-1 text-xs text-[var(--vscode-descriptionForeground)]">提示词</span>
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import type { ManagedSessionsData } from '../types'
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { onMessage, postMessage } from '../lib/vscode'
|
import { onMessage, postMessage } from '../lib/vscode'
|
||||||
|
|
||||||
|
export interface ImportableNamedSession {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mtimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseManagedSessionsResult {
|
export interface UseManagedSessionsResult {
|
||||||
managedSessions: ManagedSessionsData
|
managedSessions: ManagedSessionsData
|
||||||
createManagedSession: (profilePath: string, name?: string, prompt?: string) => void
|
createManagedSession: (profilePath: string, name?: string, prompt?: string) => void
|
||||||
@@ -19,15 +25,25 @@ export interface UseManagedSessionsResult {
|
|||||||
resumeManagedSession: (id: string) => void
|
resumeManagedSession: (id: string) => void
|
||||||
deleteManagedSession: (id: string) => void
|
deleteManagedSession: (id: string) => void
|
||||||
closeManagedSessionTab: (id: string) => void
|
closeManagedSessionTab: (id: string) => void
|
||||||
|
listImportableNamedSessions: () => void
|
||||||
|
importNamedSession: (sessionId: string, name: string) => void
|
||||||
|
importableSessions: ImportableNamedSession[]
|
||||||
|
importableLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useManagedSessions(): UseManagedSessionsResult {
|
export function useManagedSessions(): UseManagedSessionsResult {
|
||||||
const [managedSessions, setManagedSessions] = useState<ManagedSessionsData>({ sessions: [] })
|
const [managedSessions, setManagedSessions] = useState<ManagedSessionsData>({ sessions: [] })
|
||||||
|
const [importableSessions, setImportableSessions] = useState<ImportableNamedSession[]>([])
|
||||||
|
const [importableLoading, setImportableLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = onMessage((msg) => {
|
const cleanup = onMessage((msg) => {
|
||||||
if (msg.type === 'managed-sessions/show')
|
if (msg.type === 'managed-sessions/show')
|
||||||
setManagedSessions(msg.data)
|
setManagedSessions(msg.data)
|
||||||
|
if (msg.type === 'managed-sessions/importable') {
|
||||||
|
setImportableSessions(msg.sessions)
|
||||||
|
setImportableLoading(false)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
postMessage({ type: 'managed-sessions/get' })
|
postMessage({ type: 'managed-sessions/get' })
|
||||||
return cleanup
|
return cleanup
|
||||||
@@ -53,6 +69,16 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
|||||||
postMessage({ type: 'managed-sessions/close-tab', sessionId: id })
|
postMessage({ type: 'managed-sessions/close-tab', sessionId: id })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const listImportableNamedSessions = useCallback((): void => {
|
||||||
|
setImportableLoading(true)
|
||||||
|
setImportableSessions([])
|
||||||
|
postMessage({ type: 'managed-sessions/list-importable' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const importNamedSession = useCallback((sessionId: string, name: string): void => {
|
||||||
|
postMessage({ type: 'managed-sessions/import', sessionId, name })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
managedSessions,
|
managedSessions,
|
||||||
createManagedSession,
|
createManagedSession,
|
||||||
@@ -60,5 +86,9 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
|||||||
resumeManagedSession,
|
resumeManagedSession,
|
||||||
deleteManagedSession,
|
deleteManagedSession,
|
||||||
closeManagedSessionTab,
|
closeManagedSessionTab,
|
||||||
|
listImportableNamedSessions,
|
||||||
|
importNamedSession,
|
||||||
|
importableSessions,
|
||||||
|
importableLoading,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issue/remove', issueNumber: number }
|
| { type: 'issue/remove', issueNumber: number }
|
||||||
| { type: 'profiles/show', data: ProfilesData }
|
| { type: 'profiles/show', data: ProfilesData }
|
||||||
| { type: 'managed-sessions/show', data: ManagedSessionsData }
|
| { type: 'managed-sessions/show', data: ManagedSessionsData }
|
||||||
|
| { 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-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-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 }
|
| { type: 'pr-file-summary/show', issueNumber: number, path: string, summary?: string, error?: string }
|
||||||
@@ -200,6 +201,8 @@ export type WebviewToExtension
|
|||||||
| { type: 'managed-sessions/resume', sessionId: string }
|
| { type: 'managed-sessions/resume', sessionId: string }
|
||||||
| { type: 'managed-sessions/delete', sessionId: string }
|
| { type: 'managed-sessions/delete', sessionId: string }
|
||||||
| { type: 'managed-sessions/close-tab', 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-files/get', issueNumber: number }
|
||||||
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
| { type: 'pr-file-diff/get', issueNumber: number, path: string, previousPath?: string }
|
||||||
| { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string }
|
| { type: 'pr-file-summary/generate', issueNumber: number, path: string, previousPath?: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user