Files
superwork/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx
T

443 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 项目 Claude 会话管理器(底部第三个 tab)。
*
* - 上方表单:选 profile(下拉,复用 useIssues 的 ClaudeProfile 列表)+ 首个提示词
* (可选,多行)+「创建」按钮 → 主进程开一个 cc 终端 tab 并捕获会话。
* - 下方列表:每行显示名字(双击行内改名,Enter 提交、Esc 取消)、行内可改 profile、
* 创建时间;点行用该 profile resume 并写回;行尾删除按钮(仅移除记录)。
*/
import type { ClaudeProfile } from '../hooks/useIssues'
import type { ManagedSession, ManagedSessionsData } from '../types'
import { Download, Plus, Terminal, Trash2, X } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
interface ManagedSessionsPanelProps {
data: ManagedSessionsData
profiles: ClaudeProfile[]
onCreate: (profilePath: string, name?: string, prompt?: string) => void
onRename: (id: string, name: string) => void
onResume: (id: string, profilePath?: 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 {
if (!p)
return ''
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
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 ''
try {
return new Date(ts).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
catch {
return ''
}
}
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(() => {
if (selectedProfile || profiles.length === 0)
return
const official = profiles.find(p => p.name.toLowerCase() === 'offical')
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],
)
const canCreate = selectedProfile.trim() !== ''
const handleCreate = useCallback((): void => {
if (!canCreate)
return
const trimmedName = name.trim()
const trimmedPrompt = prompt.trim()
onCreate(selectedProfile, trimmedName || undefined, trimmedPrompt || undefined)
setName('')
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)]">
{/* 上方表单 */}
<div className="shrink-0 border-b border-[var(--vscode-panel-border)] p-2">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">Profile</span>
<select
value={selectedProfile}
onChange={e => setSelectedProfile(e.target.value)}
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 focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
>
{profiles.length === 0 && (
<option value="">(无可用 profile</option>
)}
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">名字</span>
<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>
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="首个提示词(可选)"
rows={2}
className="min-w-0 flex-1 resize-y 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>
<div className="flex justify-end">
<button
type="button"
onClick={handleCreate}
disabled={!canCreate}
className="flex items-center gap-1 rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus className="size-3.5" />
<span>创建</span>
</button>
</div>
</div>
</div>
{/* 下方会话列表 */}
<div className="min-h-0 flex-1 overflow-auto">
{sessions.length === 0
? (
<div className="flex h-full w-full items-center justify-center p-4 text-xs text-[var(--vscode-descriptionForeground)]">
还没有会话,先在上方创建一个
</div>
)
: (
<ul className="flex flex-col">
{sessions.map(s => (
<SessionRow
key={s.id}
session={s}
profiles={profiles}
onRename={onRename}
onResume={onResume}
onDelete={onDelete}
onCloseTab={onCloseTab}
/>
))}
</ul>
)}
</div>
</div>
)
}
interface SessionRowProps {
session: ManagedSession
profiles: ClaudeProfile[]
onRename: (id: string, name: string) => void
onResume: (id: string, profilePath?: string) => void
onDelete: (id: string) => void
onCloseTab: (id: string) => void
}
function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTab }: SessionRowProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(session.name)
const [userPicked, setUserPicked] = useState<string | undefined>(undefined)
const inputRef = useRef<HTMLInputElement | null>(null)
const pickedProfile = userPicked ?? matchStoredProfile(session.profilePath, profiles)
useLayoutEffect(() => {
if (!editing)
return
const el = inputRef.current
if (!el)
return
el.focus()
el.select()
}, [editing])
useEffect(() => {
setUserPicked(undefined)
}, [session.profilePath])
const beginEdit = useCallback((): void => {
setDraft(session.name)
setEditing(true)
}, [session.name])
const commit = useCallback((): void => {
const trimmed = draft.trim()
if (trimmed && trimmed !== session.name)
onRename(session.id, trimmed)
setEditing(false)
}, [draft, session.id, session.name, onRename])
const cancel = useCallback((): void => {
setEditing(false)
setDraft(session.name)
}, [session.name])
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 (
<li
className="group flex items-center gap-2 border-b border-[var(--vscode-panel-border)] px-2 py-1.5 hover:bg-[var(--vscode-list-hoverBackground)]"
>
<button
type="button"
onClick={resume}
title="点击恢复会话(新终端 resume"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
aria-label="恢复会话"
>
<Terminal className="size-3.5" />
</button>
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => {
if (!editing)
resume()
}}
>
{editing
? (
<input
ref={inputRef}
type="text"
value={draft}
onChange={e => setDraft(e.target.value)}
onClick={e => e.stopPropagation()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
commit()
}
else if (e.key === 'Escape') {
e.preventDefault()
cancel()
}
}}
className="w-full bg-transparent px-0 py-0 text-xs text-[var(--vscode-input-foreground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
/>
)
: (
<div className="flex min-w-0 flex-col">
<span
className="truncate text-xs"
onDoubleClick={(e) => {
e.stopPropagation()
beginEdit()
}}
title="双击改名"
>
{session.name || <span className="opacity-40">(未命名)</span>}
</span>
<span className="flex min-w-0 items-center text-[10px] text-[var(--vscode-descriptionForeground)]">
{profiles.length === 0
? (
<span className="truncate">{basename(session.profilePath)}</span>
)
: (
<select
value={pickedProfile}
title="换 profile 后点行打开;已打开的终端下次才生效"
onClick={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation()
setUserPicked(e.target.value)
}}
className="min-w-0 bg-transparent py-0 pr-1 text-[10px] text-[var(--vscode-descriptionForeground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
>
{storedMissing && (
<option value={pickedProfile}>{basename(pickedProfile) || '(未设置)'}</option>
)}
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
</select>
)}
{created ? <span className="shrink-0">{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created}</span> : null}
</span>
</div>
)}
</div>
{session.tabOpen && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onCloseTab(session.id)
}}
title="关闭会话 tab"
aria-label="关闭会话 tab"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-70 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-[var(--vscode-foreground)]"
>
<X className="size-3.5" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(session.id)
}}
title="从列表移除(不删 jsonl"
aria-label="从列表移除"
className="grid size-6 shrink-0 place-items-center rounded text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:text-state-red group-hover:opacity-70"
>
<Trash2 className="size-3.5" />
</button>
</li>
)
}