Files
superwork/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx
T
cruldra c8620e6e1b ♻️ refactor(vscode): 看板的悬停提示也改走 data-tooltip,插件网页只剩一条提示的路;lint 禁止原生 title
- 看板剩下的原生 title(顶栏按钮、工单卡片、工单详情、会话、Profile 表、改动文件树和 diff、属性表)全部改成 data-tooltip,
  禁用的按钮照样弹;只有图标、原来只靠 title 当名字的(审查勾、生成说明、属性说明的 i 图标、没头像的归属人)补上 aria-label
- 提示框按原文换行:摘修复弹框里「迁移」列的几个文件一行一个
- 代码阅读 Markdown 链接的标题写成 data-tooltip
- eslint 规则:网页 TSX 的 DOM 元素上写 title 报错,iframe 和组件自己的 title 属性不算
2026-09-27 21:39:15 +08:00

502 lines
20 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 { SessionHandoffPendingItem } from '../lib/messages'
import type { ManagedSession, ManagedSessionsData } from '../types'
import { Download, Plus, Terminal, Trash2, UserRoundPlus, X } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SelectMenu } from './ui/select-menu'
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
pendingHandoffs: SessionHandoffPendingItem[]
onHandoff: (sessionId: string) => void
onAcceptHandoff: (attachmentId: number) => 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,
pendingHandoffs,
onHandoff,
onAcceptHandoff,
}: 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>
<div className="min-w-0 flex-1">
<SelectMenu
value={selectedProfile}
onChange={setSelectedProfile}
options={profiles.length === 0
? [{ value: '', label: '(无可用 profile)' }]
: profiles.map(p => ({ value: p.path, label: p.name }))}
variant="input"
/>
</div>
</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}
data-tooltip="导入已 /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>
{pendingHandoffs.length > 0 && (
<div className="shrink-0 border-b border-[var(--vscode-panel-border)] p-2">
<div className="mb-1 text-[10px] text-[var(--vscode-descriptionForeground)]">待接收</div>
<ul className="flex flex-col gap-1">
{pendingHandoffs.map(p => (
<li key={p.attachmentId} className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs">{p.name || p.sid.slice(0, 8)}</div>
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
来自
{' '}
{p.from}
</div>
</div>
<button
type="button"
onClick={() => onAcceptHandoff(p.attachmentId)}
className="shrink-0 rounded bg-[var(--vscode-button-background)] px-2 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
>
接收
</button>
</li>
))}
</ul>
</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}
onHandoff={onHandoff}
/>
))}
</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
onHandoff: (id: string) => void
}
function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTab, onHandoff }: 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}
data-tooltip="点击恢复会话(新终端 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()
}}
data-tooltip="双击改名"
>
{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>
)
: (
<SelectMenu
value={pickedProfile}
onChange={setUserPicked}
tooltip="换 profile 后点行打开;已打开的终端下次才生效"
variant="ghost"
options={[
...(storedMissing
? [{ value: pickedProfile, label: basename(pickedProfile) || '(未设置)' }]
: []),
...profiles.map(p => ({ value: p.path, label: p.name })),
]}
/>
)}
{created
? (
<span className="shrink-0">
{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}
{created}
</span>
)
: null}
{session.handedOffTo
? (
<span className="ml-1 shrink-0 text-[10px] text-[var(--vscode-descriptionForeground)]">
· 已交
{' '}
{session.handedOffTo}
</span>
)
: null}
</span>
</div>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onHandoff(session.id)
}}
data-tooltip="交接给同事"
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-[var(--vscode-foreground)] group-hover:opacity-70"
>
<UserRoundPlus className="size-3.5" />
</button>
{session.tabOpen && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onCloseTab(session.id)
}}
data-tooltip="关闭会话 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)
}}
data-tooltip="从列表移除(不删 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>
)
}