Files
makelore/src/pages/Chat/CodingConversationHeader.tsx

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)} />}
</>
);
}