Makelore 2.0 initial clean snapshot
This commit is contained in:
211
src/pages/Chat/ChatCommandDialogs.tsx
Normal file
211
src/pages/Chat/ChatCommandDialogs.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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-black/50
|
||||
"
|
||||
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;
|
||||
mode: 'timeline' | 'fork';
|
||||
entries: readonly ChatTimelineEntry[];
|
||||
onSelect: (messageId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ChatCommandTimelineDialog({
|
||||
open,
|
||||
mode,
|
||||
entries,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: ChatCommandTimelineDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const firstEntryRef = useRef<HTMLButtonElement>(null);
|
||||
useModalDialogFocus(open, dialogRef, firstEntryRef);
|
||||
|
||||
if (!open) return null;
|
||||
const title = mode === 'fork' ? '选择分叉位置' : '消息时间线';
|
||||
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-black/50
|
||||
"
|
||||
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-bold 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user