✨ feat(vscode): 会话管理支持导入已 /rename 的命名会话
This commit is contained in:
@@ -83,6 +83,10 @@ export function App() {
|
||||
resumeManagedSession,
|
||||
deleteManagedSession,
|
||||
closeManagedSessionTab,
|
||||
listImportableNamedSessions,
|
||||
importNamedSession,
|
||||
importableSessions,
|
||||
importableLoading,
|
||||
} = useManagedSessions()
|
||||
const [showNewIssueModal, setShowNewIssueModal] = useState(false)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
@@ -373,6 +377,10 @@ export function App() {
|
||||
onManagedSessionResume={resumeManagedSession}
|
||||
onManagedSessionDelete={deleteManagedSession}
|
||||
onManagedSessionCloseTab={closeManagedSessionTab}
|
||||
importableSessions={importableSessions}
|
||||
importableLoading={importableLoading}
|
||||
onListImportableSessions={listImportableNamedSessions}
|
||||
onImportNamedSession={importNamedSession}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,10 @@ interface BottomTabsProps {
|
||||
onManagedSessionResume: (id: string) => void
|
||||
onManagedSessionDelete: (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) {
|
||||
@@ -143,6 +147,10 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
onResume={props.onManagedSessionResume}
|
||||
onDelete={props.onManagedSessionDelete}
|
||||
onCloseTab={props.onManagedSessionCloseTab}
|
||||
importableSessions={props.importableSessions}
|
||||
importableLoading={props.importableLoading}
|
||||
onListImportable={props.onListImportableSessions}
|
||||
onImport={props.onImportNamedSession}
|
||||
/>
|
||||
)}
|
||||
{tab === 'changes' && (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||
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'
|
||||
|
||||
interface ManagedSessionsPanelProps {
|
||||
@@ -20,6 +20,10 @@ interface ManagedSessionsPanelProps {
|
||||
onResume: (id: string) => void
|
||||
onDelete: (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 {
|
||||
@@ -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 [name, setName] = useState<string>('')
|
||||
const [prompt, setPrompt] = useState<string>('')
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const importWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 默认选中官方 profile(profiles 异步到达后;用户手动改选后不覆盖)。
|
||||
useEffect(() => {
|
||||
@@ -58,6 +76,29 @@ export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onRes
|
||||
setSelectedProfile((official ?? profiles[0]).path)
|
||||
}, [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(
|
||||
() => [...data.sessions].sort((a, b) => b.createdAt - a.createdAt),
|
||||
[data.sessions],
|
||||
@@ -75,6 +116,16 @@ export function ManagedSessionsPanel({ data, profiles, onCreate, onRename, onRes
|
||||
setPrompt('')
|
||||
}, [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 (
|
||||
<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 className="flex items-center gap-2">
|
||||
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">名字</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
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)]"
|
||||
/>
|
||||
<div className="relative min-w-0 flex-1" ref={importWrapRef}>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
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 className="flex items-start gap-2">
|
||||
<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 { onMessage, postMessage } from '../lib/vscode'
|
||||
|
||||
export interface ImportableNamedSession {
|
||||
id: string
|
||||
name: string
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
export interface UseManagedSessionsResult {
|
||||
managedSessions: ManagedSessionsData
|
||||
createManagedSession: (profilePath: string, name?: string, prompt?: string) => void
|
||||
@@ -19,15 +25,25 @@ export interface UseManagedSessionsResult {
|
||||
resumeManagedSession: (id: string) => void
|
||||
deleteManagedSession: (id: string) => void
|
||||
closeManagedSessionTab: (id: string) => void
|
||||
listImportableNamedSessions: () => void
|
||||
importNamedSession: (sessionId: string, name: string) => void
|
||||
importableSessions: ImportableNamedSession[]
|
||||
importableLoading: boolean
|
||||
}
|
||||
|
||||
export function useManagedSessions(): UseManagedSessionsResult {
|
||||
const [managedSessions, setManagedSessions] = useState<ManagedSessionsData>({ sessions: [] })
|
||||
const [importableSessions, setImportableSessions] = useState<ImportableNamedSession[]>([])
|
||||
const [importableLoading, setImportableLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = onMessage((msg) => {
|
||||
if (msg.type === 'managed-sessions/show')
|
||||
setManagedSessions(msg.data)
|
||||
if (msg.type === 'managed-sessions/importable') {
|
||||
setImportableSessions(msg.sessions)
|
||||
setImportableLoading(false)
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'managed-sessions/get' })
|
||||
return cleanup
|
||||
@@ -53,6 +69,16 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
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 {
|
||||
managedSessions,
|
||||
createManagedSession,
|
||||
@@ -60,5 +86,9 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
resumeManagedSession,
|
||||
deleteManagedSession,
|
||||
closeManagedSessionTab,
|
||||
listImportableNamedSessions,
|
||||
importNamedSession,
|
||||
importableSessions,
|
||||
importableLoading,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ export type ExtensionToWebview
|
||||
| { type: 'issue/remove', issueNumber: number }
|
||||
| { type: 'profiles/show', data: ProfilesData }
|
||||
| { 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-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 }
|
||||
@@ -200,6 +201,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 }
|
||||
|
||||
Reference in New Issue
Block a user