202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import {
|
|
LoaderCircle,
|
|
Pencil,
|
|
PanelRightClose,
|
|
PanelRightOpen,
|
|
RotateCcw,
|
|
Square,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
|
|
import { cn } from '@/lib/utils';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
import { TITLEBAR_LOGO_WIDTH, WINDOWS_TITLEBAR_CONTROLS_WIDTH } from '@/components/layout/titlebar-metrics';
|
|
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
|
import type { CodingConversationMetadata } from '@/types/coding-project';
|
|
|
|
const WORKSPACE_COLUMN_WIDTH = 256;
|
|
|
|
interface LocalActionError {
|
|
message: string;
|
|
backendCode?: string;
|
|
startedGeneration: number;
|
|
startedSeq: number;
|
|
}
|
|
|
|
function localActionError(
|
|
error: unknown,
|
|
startedGeneration: number,
|
|
startedSeq: number,
|
|
): LocalActionError {
|
|
const details = error && typeof error === 'object'
|
|
&& 'details' in error
|
|
&& error.details
|
|
&& typeof error.details === 'object'
|
|
? error.details as { backendCode?: unknown }
|
|
: null;
|
|
return {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}),
|
|
startedGeneration,
|
|
startedSeq,
|
|
};
|
|
}
|
|
|
|
export function CodingConversationHeader({
|
|
conversation,
|
|
snapshot,
|
|
onRename,
|
|
onAbort,
|
|
onRecover,
|
|
browserOpen = false,
|
|
browserAvailable = false,
|
|
onToggleBrowser,
|
|
}: {
|
|
conversation: CodingConversationMetadata | null;
|
|
snapshot: ConversationSnapshot | null;
|
|
onRename(title: string): Promise<void>;
|
|
onAbort(): Promise<void>;
|
|
onRecover(): Promise<void>;
|
|
browserOpen?: boolean;
|
|
browserAvailable?: boolean;
|
|
onToggleBrowser?(): void;
|
|
}) {
|
|
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 runStatus = snapshot?.run.status ?? 'preparing';
|
|
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
|
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 && (
|
|
snapshot.cursor.workerGeneration > actionError.startedGeneration
|
|
|| (snapshot.cursor.workerGeneration === actionError.startedGeneration
|
|
&& snapshot.cursor.seq > actionError.startedSeq)
|
|
));
|
|
const visibleActionError = actionError?.backendCode === 'CODING_REQUEST_UNCERTAIN'
|
|
&& !running
|
|
&& runtimeErrorCode !== 'CODING_REQUEST_UNCERTAIN'
|
|
&& 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;
|
|
const startedGeneration = snapshot?.cursor.workerGeneration ?? 0;
|
|
const startedSeq = snapshot?.cursor.seq ?? 0;
|
|
setBusyAction(key);
|
|
setActionError(null);
|
|
void action()
|
|
.catch((error) => setActionError(localActionError(error, startedGeneration, startedSeq)))
|
|
.finally(() => setBusyAction((current) => current === key ? null : current));
|
|
};
|
|
|
|
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' : 'bg-background',
|
|
platform === 'darwin' && 'pr-40',
|
|
)}
|
|
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={() => {
|
|
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>
|
|
|
|
{onToggleBrowser ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className={iconButtonClass}
|
|
disabled={!browserAvailable}
|
|
aria-label={browserOpen ? '关闭开发浏览器' : '打开开发浏览器'}
|
|
aria-pressed={browserOpen}
|
|
title={browserOpen ? '关闭开发浏览器' : '打开开发浏览器'}
|
|
onClick={onToggleBrowser}
|
|
>
|
|
{browserOpen
|
|
? <PanelRightClose className="h-4 w-4" aria-hidden="true" />
|
|
: <PanelRightOpen className="h-4 w-4" aria-hidden="true" />}
|
|
</Button>
|
|
) : null}
|
|
|
|
{running && runStatus !== 'aborting' && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className={iconButtonClass}
|
|
disabled={Boolean(busyAction) || !conversation}
|
|
aria-label="中止"
|
|
title="中止"
|
|
onClick={() => perform('abort', onAbort)}
|
|
>
|
|
{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`,
|
|
// Keep both actions and their hit area outside the product chrome.
|
|
right: platform === 'win32' ? TITLEBAR_LOGO_WIDTH + WINDOWS_TITLEBAR_CONTROLS_WIDTH : 0,
|
|
}}
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{renameOpen && conversation && <CodingConversationRenameDialog
|
|
title={conversation.title} onSave={onRename} onClose={() => setRenameOpen(false)} />}
|
|
</>
|
|
);
|
|
}
|