Files
Jyotisha/frontend/src/components/chat-composer.tsx
T
Jesse_ChenandCursor e8d201dd9a
Independent Staging Quality Gate / validate (push) Successful in 13m15s
Independent Staging Quality Gate / publish (push) Successful in 10m9s
perf(chat): isolate the composer, split heavy chunks, fix dead tokens
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>
2026-08-17 14:16:05 +08:00

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