Second batch from the staging UX audit (BUG-248..253). - css: expose the 32 palette tokens through @theme. Seven utilities including text-ink, text-danger and text-warning compiled to no CSS at all, so 30 call sites had been silently inert (BUG-248) - chat: move the composer into its own component behind a draft store, so a keystroke no longer re-renders a 2723-line component, and persist the draft across reloads (BUG-249) - chat: load gsap, react-markdown and thinking-orbs on demand. First Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250) - chat: route the five in-app destinations through router.push, and keep the five auth redirects and the bootstrap retry as hard loads on purpose (BUG-251) - a11y: announce reply completion, and move the live region out of the aria-busy subtree that was likely suppressing even the start announcement (BUG-252) - docs: give the 27 collided bug ids unique numbers and repair their inbound references; require search rather than a full read of a 3690-line file (BUG-253) Verified: tsc, eslint, next build, and 1592 assertions across the 199 non-database test files. Co-authored-by: Cursor <cursoragent@cursor.com>
89 lines
2.3 KiB
TypeScript
89 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
import type { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from "react";
|
|
import { ArrowUp, Square } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
composerDraftSnapshot,
|
|
serverComposerDraftSnapshot,
|
|
subscribeComposerDraft,
|
|
} from "@/lib/composer-draft";
|
|
|
|
export function useComposerDraft(): string {
|
|
return useSyncExternalStore(
|
|
subscribeComposerDraft,
|
|
composerDraftSnapshot,
|
|
serverComposerDraftSnapshot,
|
|
);
|
|
}
|
|
|
|
type ChatComposerProps = {
|
|
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
|
|
readonly inputLabel: string;
|
|
readonly placeholder: string;
|
|
readonly maxLength: number;
|
|
readonly inputDisabled: boolean;
|
|
readonly submitLabel: string;
|
|
readonly submitBlocked: boolean;
|
|
readonly stopVisible: boolean;
|
|
readonly stopLabel: string;
|
|
readonly stopTitle: string;
|
|
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
|
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
readonly onStop: () => void;
|
|
};
|
|
|
|
export function ChatComposer({
|
|
inputRef,
|
|
inputLabel,
|
|
placeholder,
|
|
maxLength,
|
|
inputDisabled,
|
|
submitLabel,
|
|
submitBlocked,
|
|
stopVisible,
|
|
stopLabel,
|
|
stopTitle,
|
|
onSubmit,
|
|
onChange,
|
|
onKeyDown,
|
|
onStop,
|
|
}: ChatComposerProps) {
|
|
const draft = useComposerDraft();
|
|
|
|
return (
|
|
<form className="composer" onSubmit={onSubmit}>
|
|
<Textarea
|
|
ref={inputRef}
|
|
aria-label={inputLabel}
|
|
placeholder={placeholder}
|
|
rows={1}
|
|
maxLength={maxLength}
|
|
disabled={inputDisabled}
|
|
value={draft}
|
|
onChange={onChange}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
{stopVisible ? (
|
|
<Button
|
|
className="composer-stop"
|
|
aria-label={stopLabel}
|
|
title={stopTitle}
|
|
size="icon"
|
|
type="button"
|
|
onClick={onStop}
|
|
>
|
|
<Square aria-hidden="true" />
|
|
</Button>
|
|
) : (
|
|
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
|
|
<ArrowUp aria-hidden="true" />
|
|
</Button>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|