chore(integration): snapshot local main worktree

This commit is contained in:
inman
2026-08-31 10:23:21 +08:00
parent 48a9189939
commit 33fb31fb28
116 changed files with 8108 additions and 3026 deletions

View File

@@ -1,15 +1,10 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import {
Archive,
CircleDot,
GitFork,
LoaderCircle,
PanelRight,
Pencil,
RotateCcw,
Settings2,
Square,
Unplug,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -21,32 +16,13 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import {
abortCodingConversation,
compactCodingConversation,
setCodingConversationModel,
setCodingConversationThinking,
} from '@/lib/coding-conversations';
import {
buildCodingModelOptions,
codingModelKey,
parseCodingModelKey,
} from '@/lib/coding-model-options';
import { useProviderStore } from '@/stores/providers';
import type {
ConversationSnapshot,
ConversationThinkingLevel,
} from '@/types/coding-conversation';
import { abortCodingConversation } from '@/lib/coding-conversations';
import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import type { ConversationSnapshot } from '@/types/coding-conversation';
import type { CodingConversationMetadata } from '@/types/coding-project';
const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string }> = [
{ value: 'off', label: '关闭思考' },
{ value: 'minimal', label: '极简思考' },
{ value: 'low', label: '低思考' },
{ value: 'medium', label: '中等思考' },
{ value: 'high', label: '高思考' },
];
const WORKSPACE_COLUMN_WIDTH = 256;
interface LocalActionError {
message: string;
@@ -74,67 +50,25 @@ function localActionError(
};
}
function runLabel(snapshot: ConversationSnapshot | null): string {
if (!snapshot) return '正在准备';
const { run } = snapshot;
if (run.status === 'idle') return run.terminalReason === 'aborted' ? '已中止' : '空闲';
if (run.status === 'preparing') return '正在准备';
if (run.status === 'queued') return '排队中';
if (run.status === 'running') return '生成中';
if (run.status === 'retrying') return `重试中${run.retry ? ` · 第 ${run.retry.attempt} 次` : ''}`;
if (run.status === 'compacting') return '整理上下文';
if (run.status === 'aborting') return '正在中止';
return '需要处理';
}
export function CodingConversationHeader({
conversation,
snapshot,
connectionState,
onRename,
onArchive,
onToggleUnread,
onFork,
onRefresh,
onRecover,
onOpenInspector,
onOpenSettings,
}: {
conversation: CodingConversationMetadata | null;
snapshot: ConversationSnapshot | null;
connectionState: string;
onRename(title: string): Promise<void>;
onArchive(): Promise<void>;
onToggleUnread(): Promise<void>;
onFork(): Promise<void>;
onRefresh(): Promise<void>;
onRecover(): Promise<void>;
onOpenInspector(): void;
onOpenSettings?(): void;
}) {
const accounts = useProviderStore((state) => state.accounts);
const vendors = useProviderStore((state) => state.vendors);
const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]);
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [actionError, setActionError] = useState<LocalActionError | null>(null);
const [renameOpen, setRenameOpen] = useState(false);
const [titleDraft, setTitleDraft] = useState(conversation?.title ?? '');
const model = snapshot?.conversation.model.model ?? conversation?.model ?? null;
const modelValue = model ? codingModelKey(model) : '';
const hasCurrentOption = options.some((option) => option.key === modelValue);
const thinkingLevel = model?.thinkingLevel ?? 'off';
const availableThinkingLevels = snapshot?.conversation.model.availableThinkingLevels;
const thinkingOptions = availableThinkingLevels
? THINKING_OPTIONS.filter((option) => availableThinkingLevels.includes(option.value))
: THINKING_OPTIONS;
const runStatus = snapshot?.run.status ?? 'preparing';
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
const queueCount = snapshot?.queue.items.length ?? 0;
const context = snapshot?.context;
const usedPercent = context?.contextWindow
? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100))
: 0;
const recoverable = Boolean(snapshot?.run.error?.recoverable || snapshot?.worker.error?.recoverable);
const runtimeErrorCode = snapshot?.run.error?.code ?? snapshot?.worker.error?.code;
const cursorAdvancedPastAction = Boolean(actionError && snapshot && (
@@ -148,6 +82,8 @@ export function CodingConversationHeader({
&& cursorAdvancedPastAction
? null
: actionError;
const platform = typeof window === 'undefined' ? undefined : window.electron?.platform;
const hasCustomTitleBar = platform === 'darwin' || platform === 'win32';
const perform = (key: string, action: () => Promise<void>) => {
if (busyAction || !conversation) return;
@@ -160,119 +96,87 @@ export function CodingConversationHeader({
.finally(() => setBusyAction((current) => current === key ? null : current));
};
return (
<header className="shrink-0 border-b border-foreground/10 bg-background" data-testid="coding-conversation-header">
<div className="flex min-h-14 items-center gap-3 px-4 sm:px-5">
<div className="min-w-0 flex-1">
<button
type="button"
className="group flex max-w-full items-center gap-1.5 rounded-lg text-left"
disabled={!conversation}
onClick={() => {
setTitleDraft(conversation?.title ?? '');
setRenameOpen(true);
}}
>
<span className="truncate text-sm font-semibold">{conversation?.title ?? '新对话'}</span>
<Pencil className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</button>
<p className="truncate text-xs text-muted-foreground">
{runLabel(snapshot)}
{queueCount > 0 ? ` · 队列 ${queueCount}` : ''}
{connectionState === 'reconnecting' ? ' · 正在重连' : ''}
</p>
</div>
{running && runStatus !== 'aborting' && (
<Button
type="button"
variant="outline"
className="min-h-10 rounded-xl px-3 text-xs"
disabled={Boolean(busyAction) || !conversation}
onClick={() => perform('abort', async () => {
await abortCodingConversation(conversation!.id);
})}
>
{busyAction === 'abort' ? <LoaderCircle className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" /> : <Square className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />}
中止
</Button>
)}
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-xl" aria-label="打开编程工具" disabled={!conversation} onClick={onOpenInspector}>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-xl" aria-label="伙伴设置" onClick={onOpenSettings}>
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Button>
const iconButtonClass = 'no-drag h-8 w-8 shrink-0 rounded-md p-0';
const header = (
<header
className={cn(
'drag-region flex h-10 min-w-0 items-center gap-1 border-b border-border/80 px-3',
hasCustomTitleBar ? 'bg-transparent pr-40' : 'bg-background',
)}
data-testid="coding-conversation-header"
>
<div className="flex min-w-0 flex-1 items-center">
<button
type="button"
className="no-drag group flex max-w-full items-center gap-1.5 rounded-md text-left"
disabled={!conversation}
onClick={() => {
setTitleDraft(conversation?.title ?? '');
setRenameOpen(true);
}}
>
<span className="truncate text-sm font-semibold">{conversation?.title ?? '新对话'}</span>
<Pencil className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</button>
</div>
{conversation && (
<div className="flex min-h-12 items-center gap-2 overflow-x-auto border-t border-foreground/[0.06] px-4 py-1.5 sm:px-5">
<Select
value={modelValue}
aria-label="当前对话模型"
className="h-9 min-w-52 max-w-72 text-xs"
disabled={Boolean(busyAction) || running}
onChange={(event) => {
const selected = parseCodingModelKey(event.target.value);
if (!selected) return;
perform('model', async () => {
await setCodingConversationModel(conversation.id, {
...selected,
thinkingLevel,
});
await onRefresh();
});
}}
>
{!model && <option value="">选择对话模型</option>}
{model && !hasCurrentOption && <option value={modelValue}>{model.accountId} / {model.modelId}(当前不可用)</option>}
{options.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</Select>
<Select
value={thinkingLevel}
aria-label="当前对话思考级别"
className="h-9 w-36 shrink-0 text-xs"
disabled={Boolean(busyAction) || !model || running}
onChange={(event) => perform('thinking', async () => {
await setCodingConversationThinking(
conversation.id,
event.target.value as ConversationThinkingLevel,
);
await onRefresh();
})}
>
{thinkingOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</Select>
<div className="flex min-w-44 items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-[11px] text-muted-foreground" title="当前 Conversation 的上下文使用量">
<span className="whitespace-nowrap">上下文 {context?.usedTokens ?? 0}/{context?.contextWindow ?? 0}</span>
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-foreground/10"><span className="block h-full rounded-full bg-brand" style={{ width: `${usedPercent}%` }} /></span>
{context?.recalculating && <LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-label="正在重算上下文" />}
</div>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction) || running} onClick={() => perform('compact', async () => compactCodingConversation(conversation.id))}>
<Unplug className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />整理上下文
</Button>
{recoverable && (
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('recover', onRecover)}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />恢复
</Button>
)}
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction) || running} onClick={() => perform('fork', onFork)}>
<GitFork className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />创建分支
</Button>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('unread', onToggleUnread)}>
<CircleDot className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />{conversation.unread ? '标为已读' : '标为未读'}
</Button>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs text-muted-foreground" disabled={Boolean(busyAction) || running} onClick={() => perform('archive', onArchive)}>
<Archive className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />归档
</Button>
</div>
{running && runStatus !== 'aborting' && (
<Button
type="button"
variant="outline"
className={iconButtonClass}
disabled={Boolean(busyAction) || !conversation}
aria-label="中止"
title="中止"
onClick={() => perform('abort', async () => abortCodingConversation(conversation!.id))}
>
{busyAction === 'abort'
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
: <Square className="h-3.5 w-3.5" aria-hidden="true" />}
</Button>
)}
{recoverable && (
<Button
type="button"
variant="ghost"
className={iconButtonClass}
disabled={Boolean(busyAction)}
aria-label="恢复"
title="恢复"
onClick={() => perform('recover', onRecover)}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
)}
</header>
);
return (
<>
{hasCustomTitleBar && typeof document !== 'undefined'
? createPortal(
<div
className="pointer-events-none fixed right-0 top-0 z-[80] h-10"
data-testid="coding-conversation-header-titlebar"
style={{ left: `${sidebarCollapsed ? WORKSPACE_COLUMN_WIDTH : WORKSPACE_COLUMN_WIDTH * 2}px` }}
>
<div className="pointer-events-auto h-full">{header}</div>
</div>,
document.body,
)
: header}
{visibleActionError && (
<p className="shrink-0 border-b border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">
{visibleActionError.message}
</p>
)}
{visibleActionError && <p className="border-t border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">{visibleActionError.message}</p>}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改对话标题</DialogTitle>
<DialogDescription>标题只影响当前 Conversation,不会改动伙伴名称。</DialogDescription>
<DialogDescription>标题只影响当前对话,不会改动智能体名称。</DialogDescription>
</DialogHeader>
<Input value={titleDraft} maxLength={160} aria-label="对话标题" onChange={(event) => setTitleDraft(event.target.value)} />
<DialogFooter>
@@ -289,6 +193,6 @@ export function CodingConversationHeader({
</DialogFooter>
</DialogContent>
</Dialog>
</header>
</>
);
}