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
+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>
);
}