Files
makelore/src/pages/Chat/ChatCommandDialogs.tsx
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

210 lines
5.5 KiB
TypeScript

import {
useEffect,
useRef,
type KeyboardEvent,
type RefObject,
} from 'react';
import { Button } from '@/components/ui/button';
const DIALOG_FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function useModalDialogFocus(
open: boolean,
dialogRef: RefObject<HTMLDivElement | null>,
initialFocusRef: RefObject<HTMLElement | null>,
) {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return undefined;
previousFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = dialogRef.current;
(initialFocusRef.current
?? dialog?.querySelector<HTMLElement>(DIALOG_FOCUSABLE_SELECTOR))
?.focus();
return () => {
previousFocusRef.current?.focus();
previousFocusRef.current = null;
};
}, [dialogRef, initialFocusRef, open]);
}
function handleModalKeyDown(
event: KeyboardEvent<HTMLDivElement>,
onCancel: () => void,
) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onCancel();
return;
}
if (event.key !== 'Tab') return;
const focusable = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
DIALOG_FOCUSABLE_SELECTOR,
),
);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable.at(-1) ?? first;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export interface ChatTimelineEntry {
messageId: string;
role: 'user' | 'assistant' | 'system' | 'toolresult';
label: string;
preview: string;
timestamp?: number;
}
export interface ChatCommandRenameDialogProps {
open: boolean;
title: string;
onTitleChange: (title: string) => void;
onSubmit: (title: string) => void;
onCancel: () => void;
}
export function ChatCommandRenameDialog({
open,
title,
onTitleChange,
onSubmit,
onCancel,
}: ChatCommandRenameDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useModalDialogFocus(open, dialogRef, inputRef);
if (!open) return null;
const trimmedTitle = title.trim();
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="chat-rename-title"
className="
fixed inset-0 z-50 flex items-center justify-center bg-foreground/15
"
onKeyDown={(event) => handleModalKeyDown(event, onCancel)}
>
<form
className="w-full max-w-md rounded-lg border bg-card p-6 shadow-lg"
onSubmit={(event) => {
event.preventDefault();
if (trimmedTitle) onSubmit(trimmedTitle);
}}
>
<h2 id="chat-rename-title" className="text-lg font-semibold">
</h2>
<input
ref={inputRef}
aria-label="会话标题"
value={title}
className="mt-4 w-full rounded border px-3 py-2"
onChange={(event) => onTitleChange(event.target.value)}
/>
<div className="mt-6 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit" disabled={!trimmedTitle}>
</Button>
</div>
</form>
</div>
);
}
export interface ChatCommandTimelineDialogProps {
open: boolean;
entries: readonly ChatTimelineEntry[];
onSelect: (messageId: string) => void;
onCancel: () => void;
}
export function ChatCommandTimelineDialog({
open,
entries,
onSelect,
onCancel,
}: ChatCommandTimelineDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const firstEntryRef = useRef<HTMLButtonElement>(null);
useModalDialogFocus(open, dialogRef, firstEntryRef);
if (!open) return null;
const title = '消息时间线';
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="chat-timeline-title"
className="
fixed inset-0 z-50 flex items-center justify-center bg-foreground/15
"
onKeyDown={(event) => handleModalKeyDown(event, onCancel)}
>
<div
className="
max-h-[70vh] w-full max-w-xl overflow-y-auto rounded-lg border
bg-card p-6 shadow-lg
"
>
<h2 id="chat-timeline-title" className="text-lg font-semibold">
{title}
</h2>
<div className="mt-4 space-y-2">
{entries.map((entry) => (
<button
ref={entry === entries[0] ? firstEntryRef : undefined}
key={entry.messageId}
type="button"
className="
block w-full rounded border px-3 py-2 text-left
hover:bg-muted
"
onClick={() => onSelect(entry.messageId)}
>
<span className="block text-xs font-medium text-muted-foreground">
{entry.label}
</span>
<span className="block truncate">{entry.preview}</span>
</button>
))}
</div>
<div className="mt-6 flex justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
</div>
</div>
</div>
);
}