perf(chat): isolate the composer, split heavy chunks, fix dead tokens
Independent Staging Quality Gate / validate (push) Successful in 13m15s
Independent Staging Quality Gate / publish (push) Successful in 10m9s

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>
This commit is contained in:
Jesse_Chen
2026-08-17 14:16:05 +08:00
parent 9c296f1e3f
commit e8d201dd9a
19 changed files with 1229 additions and 169 deletions
@@ -1,4 +1,9 @@
import { ThinkingOrb, type OrbState } from "thinking-orbs";
"use client";
import dynamic from "next/dynamic";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
const labels = {
working: "正在处理任务…",
@@ -9,6 +14,17 @@ const labels = {
shaping: "正在生成结果…",
} as const satisfies Record<OrbState, string>;
const importThinkingOrb = () => import("thinking-orbs");
const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, {
loading: () => (
<span aria-hidden="true" style={{ display: "block", flex: "0 0 auto", height: 20, width: 20 }} />
),
ssr: false,
});
prefetchOnIdle(importThinkingOrb);
export type AgentActivityState = OrbState;
export function AgentActivityStatus({
@@ -0,0 +1,11 @@
"use client";
export function prefetchOnIdle(load: () => Promise<unknown>) {
if (typeof window === "undefined") return;
const request = () => void load();
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(request, { timeout: 2_000 });
return;
}
window.setTimeout(request, 300);
}
+88
View File
@@ -0,0 +1,88 @@
"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>
);
}
@@ -0,0 +1,29 @@
"use client";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
const markdownComponents: Components = {
a: ({ children, href, ...props }) => (
<a {...props} href={href} rel="noreferrer" target="_blank">
{children}
</a>
),
table: ({ children }) => (
<div className="markdown-table">
<table>{children}</table>
</div>
),
};
export function renderChatMarkdown(text: string) {
return (
<ReactMarkdown
components={markdownComponents}
remarkPlugins={[remarkGfm]}
skipHtml
>
{text}
</ReactMarkdown>
);
}
@@ -1,29 +1,50 @@
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
"use client";
const markdownComponents: Components = {
a: ({ children, href, ...props }) => (
<a {...props} href={href} rel="noreferrer" target="_blank">
{children}
</a>
),
table: ({ children }) => (
<div className="markdown-table">
<table>{children}</table>
</div>
),
};
import { useEffect, useState, type ReactNode } from "react";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
type MarkdownRenderer = (text: string) => ReactNode;
let markdownRenderer: MarkdownRenderer | undefined;
let markdownRendererRequest: Promise<MarkdownRenderer> | undefined;
function loadMarkdownRenderer() {
markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => {
markdownRenderer = module.renderChatMarkdown;
return module.renderChatMarkdown;
});
return markdownRendererRequest;
}
prefetchOnIdle(loadMarkdownRenderer);
function useMarkdownRenderer() {
const [renderer, setRenderer] = useState<MarkdownRenderer | undefined>(() => markdownRenderer);
useEffect(() => {
if (renderer) return;
let active = true;
void loadMarkdownRenderer().then((loaded) => {
if (active) setRenderer(() => loaded);
});
return () => { active = false; };
}, [renderer]);
return renderer;
}
export function ChatMessageContent({ text }: { text: string }) {
const renderMarkdown = useMarkdownRenderer();
return (
<div className="message-markdown">
<ReactMarkdown
components={markdownComponents}
remarkPlugins={[remarkGfm]}
skipHtml
>
{text}
</ReactMarkdown>
{renderMarkdown
? renderMarkdown(text)
: (plainParagraphs(text) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
);
}
@@ -0,0 +1,6 @@
const markdownSyntax = /[*_#`~|<>[\]\\!&=+\r-]|:\/\/|www\.|@|\t|^[ ]|[ ]$|^\d+[.)]/m;
export function plainParagraphs(text: string): readonly string[] | null {
if (markdownSyntax.test(text)) return null;
return text.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
}
+34 -9
View File
@@ -1,16 +1,35 @@
"use client";
import { useGSAP } from "@gsap/react";
import gsapModule from "gsap";
import { useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import type { ChatMessageView } from "@/lib/chat-message-view";
const gsap = (gsapModule as typeof gsapModule & { gsap?: typeof gsapModule }).gsap ?? gsapModule;
type GsapCore = typeof import("gsap")["gsap"];
gsap.registerPlugin(useGSAP);
const useEntryEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let gsapCore: GsapCore | undefined;
let gsapRequest: Promise<GsapCore> | undefined;
function loadGsap() {
gsapRequest ??= import("gsap").then((module) => {
const core = module.gsap ?? module.default;
gsapCore = (core as GsapCore & { gsap?: GsapCore }).gsap ?? core;
return gsapCore;
});
return gsapRequest;
}
function motionPreferred() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
if (motionPreferred()) prefetchOnIdle(loadGsap);
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
@@ -40,11 +59,17 @@ export function ChatMessageRow({
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
useGSAP(() => {
if (!messageRow.current) return;
useEntryEffect(() => {
const row = messageRow.current;
if (!row) return;
if (!gsapCore) {
if (motionPreferred()) void loadGsap();
return;
}
const gsap = gsapCore;
const motion = gsap.matchMedia();
motion.add("(prefers-reduced-motion: no-preference)", () => {
gsap.fromTo(messageRow.current, {
gsap.fromTo(row, {
autoAlpha: 0,
y: message.role === "user" ? 8 : 12,
}, {
@@ -56,7 +81,7 @@ export function ChatMessageRow({
});
});
return () => motion.revert();
}, { scope: messageRow });
}, [message.role]);
return (
<article