chore(integration): snapshot local main worktree
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user