210 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|