From 524015cfc11bc015198a29782d9f707637d70007 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 08:15:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E5=9B=9E=E7=AD=94=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=EF=BC=8C=E6=AC=A1=E7=BA=A7=E9=A1=B5=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F=E6=94=B9=E7=9C=9F=E9=93=BE=E6=8E=A5=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=9B=9E=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机走查第二批: 1. 普通对话的回答,首个 H2 之后的全部内容(带推理的那一半)被
「完整分析」默认收起——用户「输出根本 看不到」。折叠控件与组件文件一并删除,报告层直接渲染进正文。 技法审计表保留自己的折叠:那是给人核对的证据,不是回复正文。 product-voice.ts 同步改掉「UI 会折叠」那句,否则模型继续按折叠写。 2. 次级页侧栏点不了头像、头像样式与首页不一致。根因是 R4 的取舍: SecondaryShell 共用,但里面挂的是新写的 AppNavRail 而不是 AppSidebar。 现在 use-nav-rail 也取 avatar,渲染同一个 UserAvatar;页脚改成真链接, 指向 / —— 账户菜单连着设置弹窗栈,留在那儿,不在这里复制第二份。 3. 「新建对话」整页刷新。原来走 location.assign,现在是 next/link。 /login 保留硬跳转:它跨鉴权边界,要先存返回目标。 注意:第一版我改成了 useRouter(),它在 app-router 上下文外会抛 「invariant expected app router to be mounted」,打红 12 条渲染测试。 改用 ,静态渲染也安全;折叠态 tooltip 换成原生 title。 测试 3369,fail 仍 31 且与基线逐条一致;四个路由标记不变; CSS gzip 40,002。 未修:截图里 `## 适合推进 / 需要避开` 被当字面量渲染,是模型输出里 那两个 H2 前面没有换行,属生成层,另记。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- frontend/src/app/globals.css | 48 +------------ frontend/src/components/app-nav-rail.tsx | 66 +++++++++++------- .../src/components/chat-answer-detail.tsx | 25 ------- .../src/components/chat-message-content.tsx | 14 ++-- frontend/src/hooks/use-nav-rail.ts | 6 ++ frontend/src/mastra/product-voice.ts | 2 +- frontend/tests/chat-answer-detail.test.ts | 69 ++++++++----------- 7 files changed, 85 insertions(+), 145 deletions(-) delete mode 100644 frontend/src/components/chat-answer-detail.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index caf0c2ef..512b592a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -363,6 +363,9 @@ button:disabled { cursor: default; opacity: .45; } .message-assistant .message-content { width: auto; max-width: none; flex: 1; } .message p { max-width: none; margin: 0; color: var(--color-ink); font-size: var(--type-body-md); line-height: 1.65; white-space: pre-wrap; } .message-markdown { color: var(--color-ink); font-size: var(--type-body-md); line-height: 1.65; } +/* The report body, inline where the collapsed 完整分析 control used to be. */ +.answer-report-body { margin-top: var(--space-4); } +.answer-report-body > .message-markdown > *:first-child { margin-top: 0; } .message-markdown > *:first-child { margin-top: 0; } .message-markdown > *:last-child { margin-bottom: 0; } .claim-boundary-badge { width: fit-content; margin: 0 0 var(--space-2); padding: var(--space-1) var(--space-2); border: 1px solid color-mix(in srgb, var(--color-action) 24%, transparent); border-radius: 999px; background: var(--color-action-soft); color: var(--color-action-hover); font-size: var(--type-overline); line-height: 1.45; } @@ -1445,50 +1448,6 @@ button:disabled { cursor: default; opacity: .45; } font-size: inherit; font-variant-numeric: tabular-nums; } -.answer-detail { - margin-top: var(--space-5); - padding-top: var(--space-3); - border-top: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); -} -.answer-detail > summary { - width: fit-content; - display: flex; - align-items: center; - gap: var(--space-2); - min-height: 32px; - margin-inline: calc(var(--space-1) * -1); - padding: 0 var(--space-2); - border-radius: var(--radius-sm); - color: var(--color-ink-tertiary); - cursor: pointer; - list-style: none; - font-size: var(--type-caption); - font-weight: 500; -} -.answer-detail > summary::-webkit-details-marker { display: none; } -.answer-detail > summary::before { - width: 6px; - height: 6px; - border-inline-end: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - content: ""; - transform: rotate(-45deg); - transition: transform 120ms ease-out; -} -.answer-detail[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); } -.answer-detail > summary:hover { background: var(--color-canvas-muted); color: var(--color-ink-secondary); } -.answer-detail > summary:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); - outline-offset: 1px; -} -.answer-detail > summary small { - color: var(--color-ink-tertiary); - font-size: inherit; - font-variant-numeric: tabular-nums; -} -.answer-detail-panel { - margin-top: var(--space-3); -} .technique-audit-panel { max-width: 100%; margin-top: var(--space-2); @@ -4801,7 +4760,6 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); } -webkit-text-fill-color: currentColor; } .technique-audit > summary::before { transition: none; } - .answer-detail > summary::before { transition: none; } } /* ============================================================ diff --git a/frontend/src/components/app-nav-rail.tsx b/frontend/src/components/app-nav-rail.tsx index 3edfb42e..6f2d1656 100644 --- a/frontend/src/components/app-nav-rail.tsx +++ b/frontend/src/components/app-nav-rail.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { usePathname } from "next/navigation"; import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react"; import { @@ -10,13 +11,13 @@ import { SidebarGroupContent, SidebarHeader, SidebarMenu, - SidebarMenuButton, SidebarMenuItem, SidebarRail, useSidebar, } from "@/components/ui/sidebar"; import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url"; import { groupSessionsByRecency } from "@/lib/session-groups"; +import { UserAvatar } from "@/components/user-avatar"; import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail"; /** @@ -47,24 +48,26 @@ export function AppNavRail() { const history = sessions.filter((session) => !session.pinned); const groups = groupSessionsByRecency(history); - /* Same navigation the chat sidebar performs when it leaves for one of these - pages: a real document load, with the return target stored first. */ - function go(href: string) { + /* Real links, not buttons with a router call. `useRouter()` throws outside an + app-router context (it took out twelve render tests), and an gives + client-side navigation for free — clicking 新建对话 from a chart used to + reload the whole document. `/login` keeps the hard exit: it crosses an auth + boundary and wants the return target persisted first. */ + function leaveForLogin() { persistLoginSessionReturn(); if (isMobile) setOpenMobile(false); - window.location.assign(href); + } + + function closeDrawer() { + if (isMobile) setOpenMobile(false); } function renderRow(session: NavRailSession) { return ( - + ); } @@ -78,23 +81,24 @@ export function AppNavRail() { - go("/")}> + + {NAV_PAGES.map(({ href, label, icon: Icon }) => ( - go(href)} + data-sidebar="menu-button" + data-active={pathname === href || pathname.startsWith(`${href}/`)} + href={href} + title={label} + onClick={closeDrawer} > + ))} @@ -136,22 +140,32 @@ export function AppNavRail() { {signedOut ? ( - + ) : ( - /* Identity only. Account actions live in the chat page's own menu; a - second copy here would need the settings dialog stack to come with it. */ -
- + /* Draws the same beam avatar the chat sidebar does — the rail used to + show only the initial, so the same account wore two different faces + depending on which page you were on. It is a real control now: the + account menu itself lives on `/` with the settings dialogs behind + it, so this goes there rather than growing a second copy. */ + + {account?.avatar + ? + : } {showExpandedContent && account ? ( <> {account.name} {account.credits} 点 ) : null} -
+ )} diff --git a/frontend/src/components/chat-answer-detail.tsx b/frontend/src/components/chat-answer-detail.tsx deleted file mode 100644 index e20bfe1e..00000000 --- a/frontend/src/components/chat-answer-detail.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; - -import { answerDetailCaption } from "@/lib/chat-answer-split"; - -export function AnswerDetailDisclosure({ - headings, - streaming = false, - children, -}: { - readonly headings: readonly string[]; - readonly streaming?: boolean; - readonly children?: ReactNode; -}) { - return ( -
- - 完整分析 - {streaming ? "正在写…" : answerDetailCaption(headings)} - - {streaming ? null :
{children}
} -
- ); -} diff --git a/frontend/src/components/chat-message-content.tsx b/frontend/src/components/chat-message-content.tsx index 5f624af2..737c2209 100644 --- a/frontend/src/components/chat-message-content.tsx +++ b/frontend/src/components/chat-message-content.tsx @@ -2,7 +2,6 @@ import { memo, useEffect, useState, type ReactNode } from "react"; -import { AnswerDetailDisclosure } from "@/components/chat-answer-detail"; import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; import { plainParagraphs } from "@/components/chat-message-paragraphs"; import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure"; @@ -139,13 +138,12 @@ export function ChatMessageContent({ : } ) : null} - {report - ? ( - - {reportBody} - - ) - : foldedAudit} + {/* The body used to sit inside a collapsed 完整分析 control: everything + from the first H2 down was one click away, so the part of the answer + that carries the reasoning was invisible by default. The answer is the + product — it renders inline. The technique audit keeps its own + disclosure, because that one is evidence for checking, not the reply. */} + {report ?
{reportBody}
: foldedAudit} ); } diff --git a/frontend/src/hooks/use-nav-rail.ts b/frontend/src/hooks/use-nav-rail.ts index ee007736..c8833ae3 100644 --- a/frontend/src/hooks/use-nav-rail.ts +++ b/frontend/src/hooks/use-nav-rail.ts @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; +import type { BeamAvatar } from "@/lib/beam-avatar"; + /** * Read-only nav data for the secondary pages (/chart, /ephemeris, /reports). * @@ -23,6 +25,8 @@ export type NavRailAccount = { readonly name: string; readonly initial: string; readonly credits: number; + /** The same beam avatar the chat sidebar draws, so the two footers match. */ + readonly avatar: BeamAvatar | null; }; export type NavRailState = { @@ -86,6 +90,7 @@ export function useNavRail(): NavRailState { credits?: unknown; user?: { email?: unknown }; profile?: { name?: unknown }; + avatar?: BeamAvatar | null; } | null; const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : ""; const email = typeof body?.user?.email === "string" ? body.user.email : ""; @@ -94,6 +99,7 @@ export function useNavRail(): NavRailState { name: name || email || "账户", initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你", credits: typeof body?.credits === "number" ? body.credits : 0, + avatar: body?.avatar ?? null, }); } } else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) { diff --git a/frontend/src/mastra/product-voice.ts b/frontend/src/mastra/product-voice.ts index 82e3a7a4..c4e217ac 100644 --- a/frontend/src/mastra/product-voice.ts +++ b/frontend/src/mastra/product-voice.ts @@ -13,7 +13,7 @@ Never open with 根据系统计算, 根据当前计算结果, 综合来看, 需 export const natalSpokenReportContract = `NATAL REPORT CONTRACT (spoken reply first, then skill Level 2 skeleton) The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level 2 skeleton — not the opening paragraphs. -For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI folds everything from the first H2 into a collapsed 完整分析 control; you must still write the full skeleton in the body — the fold is visibility, not omission. Do not hide tables in a collapsed control as the only copy. +For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full skeleton in the body and assume every line of it is read. Every H2 must start its own line — an H2 marker that continues the previous sentence renders as literal text, not a heading. Required body order after that opener, using these exact H2 headings: ## 统一参数与原始结构, then ## {Chinese domain label} for each executed domain, then ## 技法审计表, then ## 现代生活. Do not write a thinking-process checklist; Activity is server-owned. 1. Unified parameters and raw structure first (degrees, houses, vargas, Dasha boundaries, Shadbala/AV, functional benefic/malefic, Western layers the server delivered). diff --git a/frontend/tests/chat-answer-detail.test.ts b/frontend/tests/chat-answer-detail.test.ts index 7ce3a33e..f7310ef7 100644 --- a/frontend/tests/chat-answer-detail.test.ts +++ b/frontend/tests/chat-answer-detail.test.ts @@ -2,43 +2,51 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +/** + * 原值:这份文件断言回答的第二层是一个默认收起的 `
` + * 「完整分析」控件(`chat-answer-detail.tsx`),并锁住它的 caption、流式期 + * 不渲染子节点、以及它与 technique-audit 是两套样式。 + * 新值:控件与组件文件一并删除,报告层直接渲染进正文 `.answer-report-body`。 + * 原因:产品在真机走查后拍板——首个 H2 之后的全部内容(也就是带推理的那一半) + * 默认不可见,用户「输出根本看不到」。回答本身就是产品,不该藏在一次点击后面。 + * 技法审计表保留它自己的折叠:那是给人核对的证据,不是回复正文。 + * + * 下面保留的断言是原文件里与折叠无关、仍然成立的那些(记忆化、复制取原文、 + * onboarding 走同一渲染链),并新增两条锁住「不得再把回答折起来」。 + */ + const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8"); -const disclosureSource = readFileSync(new URL("../src/components/chat-answer-detail.tsx", import.meta.url), "utf8"); const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8"); const onboardingSource = readFileSync(new URL("../src/components/onboarding-chat-message.tsx", import.meta.url), "utf8"); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); -test("the answer fold is a closed details element with a chapter caption", () => { - assert.match(disclosureSource, /
/); - assert.doesNotMatch(disclosureSource, /]*\sopen/); - assert.match(disclosureSource, /完整分析/); - assert.match(disclosureSource, /answerDetailCaption\(headings\)/); - assert.doesNotMatch(disclosureSource, />展开 { + assert.match(contentSource, /
\{reportBody\}<\/div>/); + assert.doesNotMatch(contentSource, /AnswerDetailDisclosure/); + // 只查真正的折叠元素——解释这段历史的注释本身会提到「完整分析」四个字。 + assert.doesNotMatch(contentSource, /
{ - assert.match(disclosureSource, /streaming \? "正在写…" : answerDetailCaption\(headings\)/); - assert.match(disclosureSource, /\{streaming \? null :
\{children\}<\/div>\}/); - assert.doesNotMatch(disclosureSource, /spinner|骨架|正在加载|InlineSpinner|aria-busy/i); - assert.match(contentSource, /reportBody = !streaming && report/); +test("the model is told the answer renders inline, so it stops writing for a fold", () => { + const voice = readFileSync(new URL("../src/mastra/product-voice.ts", import.meta.url), "utf8"); + assert.match(voice, /renders the whole answer inline/); + assert.doesNotMatch(voice, /folds everything from the first H2/); + // 截图里 `## 适合推进 / 需要避开` 接在上一句后面,被当成字面量渲染。 + assert.match(voice, /Every H2 must start its own line/); }); -test("ChatMessageContent splits spoken from report and only folds when a report exists", () => { +test("ChatMessageContent still splits spoken from report and keeps the audit fold", () => { assert.match(contentSource, /splitSpokenAndReport\(split\.spoken\)/); - assert.match(contentSource, /report\s*\n\s*\? \(\s*\n\s* 0 && split\.rows\.length === 0/); }); -test("the folded technique audit moves inside the report fold when a report exists", () => { - // 原值: `{renderProse(report, renderMarkdown)}` 每次重渲全量 parse。 - // 新值: 报告层走 `StableMarkdownPrefix`。 - // 原因: BUG-725 结算态 Markdown 按文本记忆化。 +test("the settled report layer stays memoised by text", () => { + // 原值/新值不变,原因仍是 BUG-725:结算态 Markdown 按文本记忆化,不每次重 parse。 assert.match(contentSource, /
\s*/); - assert.match(contentSource, /\{foldedAudit\}/); assert.match(contentSource, /const reportBody = !streaming && report/); - assert.match(contentSource, /AnswerDetailDisclosure headings=\{layers\.headings\} streaming=\{streaming\}/); - assert.match(contentSource, /\{reportBody\}/); }); test("spoken streaming still uses StreamingMarkdown so the memoised prefix stays intact", () => { @@ -52,22 +60,3 @@ test("copy still sends the original message text, not the spoken layer", () => { test("onboarding typewriter messages still go through ChatMessageContent", () => { assert.match(onboardingSource, //); }); - -test("answer-detail styles are a sibling of technique-audit, not the same class", () => { - assert.match(globalStyles, /\.answer-detail \{/); - assert.match(globalStyles, /\.answer-detail > summary \{/); - assert.match(globalStyles, /\.answer-detail-panel \{/); - assert.match(disclosureSource, /className="answer-detail"/); - assert.doesNotMatch(disclosureSource, /className="technique-audit"/); -}); - -test("the fold does not introduce a spinner or skeleton class", () => { - assert.doesNotMatch(disclosureSource, /spinner|skeleton|starter-loading|InlineSpinner/i); - assert.doesNotMatch(contentSource, /正在加载/); - assert.doesNotMatch(globalStyles, /\.answer-detail[^{]*spinner/i); -}); - -test("streaming copy is a quiet line, not a loading phrase", () => { - assert.match(disclosureSource, /正在写…/); - assert.doesNotMatch(disclosureSource, /正在加载/); -});