fix(ui): 回答不再折叠,次级页侧栏改真链接并补回头像
Independent Staging Quality Gate / validate (push) Canceled after 8m15s
Independent Staging Quality Gate / publish (push) Canceled after 0s

真机走查第二批:

1. 普通对话的回答,首个 H2 之后的全部内容(带推理的那一半)被
   <details class="answer-detail">「完整分析」默认收起——用户「输出根本
   看不到」。折叠控件与组件文件一并删除,报告层直接渲染进正文。
   技法审计表保留自己的折叠:那是给人核对的证据,不是回复正文。
   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 条渲染测试。
   改用 <Link>,静态渲染也安全;折叠态 tooltip 换成原生 title。

测试 3369,fail 仍 31 且与基线逐条一致;四个路由标记不变;
CSS gzip 40,002。

未修:截图里 `## 适合推进 / 需要避开` 被当字面量渲染,是模型输出里
那两个 H2 前面没有换行,属生成层,另记。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 08:15:25 +00:00
co-authored by Claude Opus 5
parent 49bc4a8ba1
commit 524015cfc1
7 changed files with 85 additions and 145 deletions
+3 -45
View File
@@ -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; }
}
/* ============================================================
+40 -26
View File
@@ -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 <a href> 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 (
<SidebarMenuItem key={session.id}>
<button
className="session-row nav-rail-row"
type="button"
onClick={() => go(sessionHref("", session.id))}
>
<Link className="session-row nav-rail-row" href={sessionHref("", session.id)} onClick={closeDrawer}>
<span className="session-title">{session.title}</span>
</button>
</Link>
</SidebarMenuItem>
);
}
@@ -78,23 +81,24 @@ export function AppNavRail() {
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="new-chat" type="button" tooltip="新对话" onClick={() => go("/")}>
<Link className="new-chat" data-sidebar="menu-button" href="/" title="新对话" onClick={closeDrawer}>
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span></span> : null}
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
<SidebarMenuItem key={href}>
<SidebarMenuButton
<Link
className="report-nav-button"
type="button"
tooltip={label}
isActive={pathname === href || pathname.startsWith(`${href}/`)}
onClick={() => go(href)}
data-sidebar="menu-button"
data-active={pathname === href || pathname.startsWith(`${href}/`)}
href={href}
title={label}
onClick={closeDrawer}
>
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
@@ -136,22 +140,32 @@ export function AppNavRail() {
<SidebarFooter className="sidebar-footer">
{signedOut ? (
<button className="nav-rail-identity" type="button" onClick={() => go("/login")}>
<a className="nav-rail-identity" href="/login" onClick={leaveForLogin}>
<span className="profile-initial" aria-hidden="true">·</span>
{showExpandedContent ? <span><b></b></span> : null}
</button>
</a>
) : (
/* 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. */
<div className="nav-rail-identity" aria-label={account ? `${account.name},余额 ${account.credits}` : undefined}>
<span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>
/* 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. */
<Link
className="nav-rail-identity"
href="/"
aria-label={account ? `${account.name},余额 ${account.credits} 点,打开账户` : "打开账户"}
onClick={closeDrawer}
>
{account?.avatar
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
{showExpandedContent && account ? (
<>
<span><b>{account.name}</b></span>
<small>{account.credits} </small>
</>
) : null}
</div>
</Link>
)}
</SidebarFooter>
<SidebarRail />
@@ -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 (
<details className="answer-detail">
<summary>
<small>{streaming ? "正在写…" : answerDetailCaption(headings)}</small>
</summary>
{streaming ? null : <div className="answer-detail-panel">{children}</div>}
</details>
);
}
@@ -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({
: <StableMarkdownPrefix text={spoken} renderMarkdown={renderMarkdown} />}
</div>
) : null}
{report
? (
<AnswerDetailDisclosure headings={layers.headings} streaming={streaming}>
{reportBody}
</AnswerDetailDisclosure>
)
: 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 ? <div className="answer-report-body">{reportBody}</div> : foldedAudit}
</div>
);
}
+6
View File
@@ -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) {
+1 -1
View File
@@ -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 36 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 23 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 36 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 23 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).
+29 -40
View File
@@ -2,43 +2,51 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
/**
* `<details class="answer-detail">`
* `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, /<details className="answer-detail">/);
assert.doesNotMatch(disclosureSource, /<details[^>]*\sopen/);
assert.match(disclosureSource, /完整分析/);
assert.match(disclosureSource, /answerDetailCaption\(headings\)/);
assert.doesNotMatch(disclosureSource, />展开</);
test("the report layer renders inline; the answer is never folded away", () => {
assert.match(contentSource, /<div className="answer-report-body">\{reportBody\}<\/div>/);
assert.doesNotMatch(contentSource, /AnswerDetailDisclosure/);
// 只查真正的折叠元素——解释这段历史的注释本身会提到「完整分析」四个字。
assert.doesNotMatch(contentSource, /<details/);
assert.match(globalStyles, /\.answer-report-body \{/);
assert.doesNotMatch(globalStyles, /\.answer-detail\b/);
});
test("streaming keeps the fold closed and does not render report children", () => {
assert.match(disclosureSource, /streaming \? "正在写…" : answerDetailCaption\(headings\)/);
assert.match(disclosureSource, /\{streaming \? null : <div className="answer-detail-panel">\{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*<AnswerDetailDisclosure/);
assert.match(contentSource, /: foldedAudit/);
assert.match(contentSource, /showFoldedAudit = rows\.length > 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, /<div className="message-markdown">\s*<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
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, /<ChatMessageContent text=\{protectedVisibleText\} \/>/);
});
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, /正在加载/);
});