Files
Jyotisha/frontend/src/components/chat-message-row.tsx
T
Jesse_Chen 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

108 lines
3.2 KiB
TypeScript

"use client";
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";
type GsapCore = typeof import("gsap")["gsap"];
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" />;
}
export function ChatMessageRow({
message,
showActivity = message.state !== "settled",
}: Readonly<{
message: ChatMessageView;
showActivity?: boolean;
}>) {
const messageRow = useRef<HTMLElement>(null);
const assistantLabel = message.state === "thinking"
? "Jyotisha 正在分析"
: message.state === "streaming"
? "Jyotisha 正在回答"
: "Jyotisha";
const activityState = message.activity
? ({
"loading-method": "searching",
"chart-calculation": "solving",
"evidence-validation": "working",
"answer-composition": "composing",
} as const)[message.activity.phase]
: message.state === "thinking" ? "working" : "composing";
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
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(row, {
autoAlpha: 0,
y: message.role === "user" ? 8 : 12,
}, {
autoAlpha: 1,
clearProps: "opacity,transform,visibility",
duration: 0.18,
ease: "cubic-bezier(.22, 1, .36, 1)",
y: 0,
});
});
return () => motion.revert();
}, [message.role]);
return (
<article
ref={messageRow}
className={`message message-${message.role}`}
aria-label={message.role === "assistant" ? assistantLabel : "你"}
>
{message.role === "assistant" && <AgentAvatar />}
<div className="message-content">
<div className="message-bubble">
{message.role === "assistant" ? (
<>
{showActivity && (
<AgentActivityStatus state={activityState} label={activityLabel} />
)}
{message.text && <ChatMessageContent text={message.text} />}
</>
) : <p>{message.text}</p>}
</div>
</div>
</article>
);
}