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>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user