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
co-authored by Cursor
parent 9c296f1e3f
commit e8d201dd9a
19 changed files with 1229 additions and 169 deletions
+32
View File
@@ -14,6 +14,38 @@
--color-border: #d8d6cf;
--color-input: #b8b5ad;
--color-ring: #85432f;
--color-action: var(--color-action);
--color-action-soft: var(--color-action-soft);
--color-action-hover: var(--color-action-hover);
--color-action-on-dark: var(--color-action-on-dark);
--color-focus: var(--color-focus);
--color-ink: var(--color-ink);
--color-ink-strong: var(--color-ink-strong);
--color-ink-secondary: var(--color-ink-secondary);
--color-ink-tertiary: var(--color-ink-tertiary);
--color-canvas: var(--color-canvas);
--color-canvas-soft: var(--color-canvas-soft);
--color-canvas-muted: var(--color-canvas-muted);
--color-canvas-strong: var(--color-canvas-strong);
--color-sidebar: var(--color-sidebar);
--color-sidebar-solid: var(--color-sidebar-solid);
--color-selected: var(--color-selected);
--color-surface-dark: var(--color-surface-dark);
--color-surface-dark-raised: var(--color-surface-dark-raised);
--color-surface-dark-soft: var(--color-surface-dark-soft);
--color-on-dark: var(--color-on-dark);
--color-border-strong: var(--color-border-strong);
--color-danger: var(--color-danger);
--color-danger-muted: var(--color-danger-muted);
--color-success: var(--color-success);
--color-success-muted: var(--color-success-muted);
--color-warning: var(--color-warning);
--color-scrim: var(--color-scrim);
--color-frosted: var(--color-frosted);
--color-light-hover: var(--color-light-hover);
--color-dark-hover: var(--color-dark-hover);
--color-dark-muted: var(--color-dark-muted);
--color-dark-border: var(--color-dark-border);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
+81 -61
View File
@@ -2,7 +2,8 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowDown, ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { ArrowDown, ArrowUpRight, Sparkles, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AppSidebar } from "@/components/app-sidebar";
@@ -31,9 +32,9 @@ import {
LocationSearchCombobox,
type ResolvedBirthLocation,
} from "@/components/location-search-combobox";
import { Button } from "@/components/ui/button";
import { ChatComposer } from "@/components/chat-composer";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
import {
@@ -70,6 +71,7 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { chatReplyAnnouncement, type ChatReplyPhase } from "@/lib/chat-reply-announcement";
import {
requestBirthTimeAssessment,
type JourneyClientResponse,
@@ -189,6 +191,11 @@ type ChatSession = {
};
type RequestError = { sessionId: string; message: string };
type ReplyOutcome = {
readonly sessionId: string;
readonly phase: Extract<ChatReplyPhase, "completed" | "stopped" | "failed">;
readonly replyOrdinal: number;
};
type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView };
type BirthPlace = {
label: string;
@@ -833,7 +840,7 @@ function OnboardingChatMessage({ role, text, streaming = false, length = text.le
streaming ? (
<>
<div className={`onboarding-stream ${length >= text.length ? "is-complete" : ""}`} aria-hidden="true"><ChatMessageContent text={protectedVisibleText} /></div>
<span className="sr-only" aria-live="polite">{length >= text.length ? text : ""}</span>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{length >= text.length ? text : ""}</span>
</>
) : <ChatMessageContent text={protectedVisibleText} />
) : <p>{protectedVisibleText}</p>}
@@ -993,6 +1000,7 @@ async function patchSessionModel(sessionId: string, modelId: string, signal?: Ab
}
export default function Home() {
const router = useRouter();
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
@@ -1019,14 +1027,14 @@ export default function Home() {
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(null);
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
const [activeSessionId, setActiveSessionId] = useState("");
const [draft, setDraft] = useState("");
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
const [draftEntrypoint, setDraftEntrypoint] = useState<ConsultationEntrypoint | null>(null);
const draftTheme = useRef<Theme | null>(null);
const draftEntrypoint = useRef<ConsultationEntrypoint | null>(null);
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | "recovering" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
createBirthTimeConsultationConsentState,
@@ -1110,6 +1118,11 @@ export default function Home() {
const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.activity
: undefined;
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
const replyPhase: ChatReplyPhase = isLoading
? consultationPhase === "recovering" ? "recovering" : "generating"
: activeReplyOutcome?.phase ?? "idle";
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
const accountId = account?.user.id;
const rectificationCardAction = resolveRectificationEntryAction(
rectificationEntrySummary ?? {
@@ -1270,6 +1283,18 @@ export default function Home() {
&& !conversationAnchor.anchored
&& Boolean(activeSession?.messages.length);
function setDraft(value: string) {
setComposerDraft(value);
}
function setDraftTheme(theme: Theme | null) {
draftTheme.current = theme;
}
function setDraftEntrypoint(entrypoint: ConsultationEntrypoint | null) {
draftEntrypoint.current = entrypoint;
}
function restoreConsultationRecovery(session: ChatSession, requestId: string) {
if (pendingConsultation.current) return;
const lastMessage = session.messages.at(-1);
@@ -1597,7 +1622,7 @@ export default function Home() {
setConsultationPhase(null);
setStreamingReply(null);
setRequestError(null);
setComposerNotice("回答已恢复。");
setComposerNotice("回答已恢复,已显示在对话区末尾。");
void refreshAccount();
return;
}
@@ -2208,7 +2233,7 @@ export default function Home() {
}
async function saveOnboardingName() {
const name = draft.replace(/\s+/g, " ").trim().slice(0, 80);
const name = composerDraftSnapshot().replace(/\s+/g, " ").trim().slice(0, 80);
if (!name || !account || profileSaving) return;
const nextProfile = { ...profileDraft, name };
setProfileSaving(true);
@@ -2649,6 +2674,7 @@ export default function Home() {
const pending = pendingConsultation.current;
if (!pending || pending.cancelled) return;
setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 });
const isPreview = process.env.NODE_ENV === "development" && uiPreview.current;
if (pending.phase !== "undo" && !isPreview) {
stoppedRequestAwaitingSettlement.current = pending.requestId;
@@ -2711,7 +2737,7 @@ export default function Home() {
setPendingSessionId(null);
setConsultationPhase(null);
setStreamingReply(null);
setComposerNotice("回答已恢复。");
setComposerNotice("回答已恢复,已显示在对话区末尾。");
void refreshAccount();
return;
}
@@ -2841,7 +2867,7 @@ export default function Home() {
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription) {
window.location.assign(membershipHref("insufficient-credits"));
router.push(membershipHref("insufficient-credits"));
return false;
}
@@ -2865,6 +2891,7 @@ export default function Home() {
const previousOnboardingState = onboardingJustCompleted;
cancellationFeedbackRequest.current = null;
setRequestError(null);
setReplyOutcome(null);
setComposerNotice("");
consultationStatusMissingCount.current = 0;
setPendingSessionId(sessionId);
@@ -2997,7 +3024,7 @@ export default function Home() {
const contentType = response.headers.get("content-type") ?? "";
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
if (response.status === 401) window.location.assign("/login");
if (response.status === 402) window.location.assign(membershipHref("insufficient-credits"));
if (response.status === 402) router.push(membershipHref("insufficient-credits"));
throw new ConsultationResponseError(
response.status,
payloadMessage(errorPayload, "服务暂时不可用"),
@@ -3092,6 +3119,11 @@ export default function Home() {
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
setReplyOutcome({
sessionId,
phase: "completed",
replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length,
});
completeConsultationInterface(requestId);
void refreshAccount();
return true;
@@ -3101,6 +3133,7 @@ export default function Home() {
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && caught instanceof ConsultationResponseError) {
setRequestError({ sessionId, message: caught.message });
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(caught.message);
completeConsultationInterface(requestId);
return false;
@@ -3144,7 +3177,7 @@ export default function Home() {
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
return;
}
void send(draft, draftTheme ?? undefined, draftEntrypoint);
void send(composerDraftSnapshot(), draftTheme.current ?? undefined, draftEntrypoint.current);
}
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
@@ -3165,8 +3198,8 @@ export default function Home() {
if (!account) {
return (
<main className="app-loading app-loading-error" aria-live="assertive">
<div className="app-loading-content">
<main className="app-loading app-loading-error">
<div className="app-loading-content" role="alert">
<strong> Jyotisha</strong>
<span>{accountError}</span>
<div className="app-loading-actions">
@@ -3200,6 +3233,7 @@ export default function Home() {
return (
<SidebarProvider escapeBlocked={accountMenuOpen || modalOpen}>
<main className="chat-app">
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
<AppSidebar
sessions={sidebarSessions}
activeSessionId={activeSession?.id ?? null}
@@ -3235,10 +3269,10 @@ export default function Home() {
}}
onAccountMenuOpenChange={setAccountMenuOpen}
onNewChat={() => void startNewChat()}
onOpenReports={() => window.location.assign("/reports")}
onOpenReports={() => router.push("/reports")}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenRedeem={() => window.location.assign(membershipHref("account-menu"))}
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
@@ -3264,7 +3298,7 @@ export default function Home() {
<strong>{activeSession?.title || "新对话"}</strong>
</div>
<div className="chat-header-actions">
<button className="credit-button" type="button" onClick={() => window.location.assign(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<button className="credit-button" type="button" onClick={() => router.push(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
@@ -3445,7 +3479,6 @@ export default function Home() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
<span className="sr-only" aria-live="polite">{isLoading ? "Jyotisha 正在回答" : ""}</span>
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => (
<ChatMessageRow key={message.renderKey} message={message} />
))}
@@ -3497,48 +3530,35 @@ export default function Home() {
))}
</div>
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composerInput}
aria-label={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={!account
? "正在读取账户…"
: !profileComplete
? onboardingStep === "name"
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
: "请先完成上方资料"
: account.credits === 0 && !account.activeSubscription
? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"}
rows={1}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
disabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
}}
onKeyDown={handleComposerKeyDown}
/>
{isLoading ? (
<Button
className="composer-stop"
aria-label={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
title={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
size="icon"
type="button"
onClick={() => void stopResponse()}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<ChatComposer
inputRef={composerInput}
inputLabel={!profileComplete && onboardingStep === "name" ? "输入你的称呼" : "输入你的问题"}
placeholder={!account
? "正在读取账户…"
: !profileComplete
? onboardingStep === "name"
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
: "请先完成上方资料"
: account.credits === 0 && !account.activeSubscription
? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
onSubmit={submit}
onChange={(event) => {
setDraft(event.target.value);
setDraftTheme(null);
setDraftEntrypoint(null);
setComposerNotice("");
}}
onKeyDown={handleComposerKeyDown}
onStop={() => void stopResponse()}
/>
<div className="composer-footer">
<ModelSelector
models={modelCatalog?.models ?? []}