feat(sidebar): the avatar opens the account menu on every page (S2)

Off / the footer was a plain link home. It is now the same Menu.Root
everywhere; off / its dialog entries link to /?account=<dialog>, which
Home opens once its account is ready and drops from the URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 00:43:43 +08:00
co-authored by Claude Opus 5.5
parent 8415fdca72
commit c15def0c50
9 changed files with 332 additions and 63 deletions
+25 -11
View File
@@ -508,14 +508,29 @@ one skeleton, none of them carrying the sidebar.
`.session-main`, so they lost the 44px minimum, the row padding and the 2px
current-item marker, while `.session-row` still reserved a 44px column for a
menu button it never rendered.
- **What read-only drops — exactly three things.** The per-row menu trigger (and
with it the reserved column, via `.session-row[data-readonly="true"]`), the
footer chevron, and the account menu behind it. Everything else is the same
element, the same class and the same size: brand row, 新建对话, the three page
actions, the flat 「最近」 list, the 56px `.profile-trigger`. Rows are links to
`/?c=<uuid>`; the footer is a link to `/`. Renaming, pinning, archiving and
deleting stay on `/`, backed by `Home()`'s optimistic-update and rollback
layer, which is far more than these four routes need.
- **What read-only drops — exactly one thing.** The per-row menu trigger (and
with it the reserved column, via `.session-row[data-readonly="true"]`).
Everything else is the same element, the same class and the same size: brand
row, 新建对话, the page actions, the flat 「最近」 list, the 56px
`.profile-trigger` with its chevron. Rows are links to `/?c=<uuid>`.
Renaming, pinning, archiving and deleting stay on `/`, backed by `Home()`'s
optimistic-update and rollback layer, which is far more than these routes
need.
- **The avatar opens the account menu on every page (2026-09-28,
TASK-self-edit-avatar-menu-20260928 S2).** It used to be a plain link to `/`
off the home page, so clicking the avatar on 星盘 / 星历 / 我的报告 / 星盘档案
just left the page. Now it is the same `Menu.Root` everywhere — same popup,
same identity block, same items. On `/` Home controls it and 个人资料 / 通用设置
/ 账户与点数 / 退出登录 open their dialogs in place. Elsewhere the menu keeps
its own open state and those four entries are links to `/?account=<dialog>`:
Home reads the intent once on mount, drops it from the address bar at once
(a reload or Back never reopens it), and opens that dialog when its account
is ready — no waiting state of its own. The theme choice is not a dialog and
applies where the reader is. While the account is still being read the
footer is the same 56px block, inert and without a link; signed out it is
去登录. The dialogs themselves stay on `/`; moving them into every page would
add a loading state to the home first frame (§ reveal rule), so it was not
done.
- **New chat is not a home link.** The read-only 新建对话 action links to
`/?new=1`; Home consumes that intent before activation and opens a fresh local
consultation. It clears `new`, any `c`, and the login-return stub. Before the
@@ -523,8 +538,7 @@ one skeleton, none of them carrying the sidebar.
The first question uses the existing persistence flow. A reserved older
consultation keeps recovering in the background without taking over this
explicit new-chat landing or leaving its startup recovery notice there.
The account footer
still links to `/`, and a bare-home refresh keeps its existing landing rule.
A bare-home refresh keeps its existing landing rule.
The same action and mobile drawer-close behavior are retained; no extra
button, copy, loading state, or visual treatment is introduced.
- **Leaving the chat is a navigation, not a reload.** 星盘 / 星历 / 我的报告 are
@@ -782,7 +796,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。打字回答的 live 行从发出那一帧起就是阶段句「收到,正在对照你的档案…」,按服务端 `turn.progress` 换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」,属于流式生成中(BUG-1047)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户页脚或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、其余一律当前人物的空白首页,同 BUG-1052),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。
**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户菜单的弹窗项(`/?account=`)或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、其余一律当前人物的空白首页,同 BUG-1052),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。
**打开 `/` 落在哪里(2026-09-27,BUG-1052)。** 地址栏带 `?c=` 就进那条对话(分享链接、侧栏历史、在对话里刷新都靠它);带 `?new=1` 是新建对话;其余——登录后、手输网址、书签、刷新裸 `/`——一律是当前人物的空白首页(开场问候 + 输入框),已有一个没发过消息的新对话就复用它。不再记「上次在哪条对话」:离开首页、登录过期都不存任何返回记录,后台还在恢复的旧回答也不把空白首页切走。
+2 -1
View File
@@ -46,7 +46,7 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { bindCurrentSubjectAccount, readCurrentSubjectId, useCurrentSubjectProfile } from "@/lib/current-subject";
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
import { SynastryDeepLink } from "@/components/people-home-links";
import { AccountDialogDeepLink, SynastryDeepLink } from "@/components/people-home-links";
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
@@ -1163,6 +1163,7 @@ export default function Home() {
setDailyStarlanguage={setDailyStarlanguage}
/>
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
<AccountDialogDeepLink ready={hydrated && Boolean(account)} open={openAccountDialog} />
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
{pendingSessionDeletion ? (
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
+61 -39
View File
@@ -16,9 +16,10 @@ import {
Users,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import { AppLink } from "@/components/app-link";
import { accountDialogHref, type AccountDialogIntent } from "@/lib/account-dialog-intent";
import { newChatHref, sessionHref } from "@/lib/chat-session-url";
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
import { prefetchBillingPanel } from "@/lib/billing-panel-loader";
@@ -57,9 +58,10 @@ export type SidebarAccount = {
/**
* Everything only `/` can supply: the session-management layer with its
* optimistic updates and rollback, and the account menu with the settings
* dialogs behind it. Omit `controls` and the same sidebar renders read-only —
* rows are links, the footer is a link, nothing writes.
* optimistic updates and rollback, and the settings dialogs behind the
* account menu. Omit `controls` and the same sidebar renders read-only — rows
* are links, nothing writes — and the same account menu's dialog entries
* become links to `/?account=<dialog>` (TASK-self-edit-avatar-menu-20260928 S2).
*
* `/chart`, `/ephemeris` and `/reports` used to carry a second sidebar
* component of their own, deleted in this round. Two components meant two sets
@@ -133,12 +135,12 @@ export function AppSidebar({
Compiler rule treats every member of an object that carries a ref as a ref
read during render, and `accountTriggerRef` lives in here. */
const {
accountMenuOpen = false,
accountMenuOpen: controlledMenuOpen = false,
accountTriggerRef,
newChatDisabled = false,
creatingSession = false,
sessionControls,
onAccountMenuOpenChange,
onAccountMenuOpenChange: controlledMenuOpenChange,
onNewChat,
onSelectSession,
onOpenProfile,
@@ -146,6 +148,10 @@ export function AppSidebar({
onOpenBilling,
onOpenLogout,
}: Partial<AppSidebarControls> = controls ?? {};
/* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */
const [ownMenuOpen, setOwnMenuOpen] = useState(false);
const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen;
const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen;
const hasMoreSessions = sessionControls?.hasMore ?? false;
const onLoadMoreSessions = sessionControls?.onLoadMore;
const favoriteSessions = sessions.filter((session) => session.pinned);
@@ -228,6 +234,29 @@ export function AppSidebar({
);
}
/* One entry of the account menu: on `/` the dialog opens in place; on
another page the entry is a link to `/?account=<dialog>` (S2). */
function accountEntry(
dialog: AccountDialogIntent,
onOpen: (() => void) | undefined,
className: string,
content: ReactNode,
pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {},
) {
return onOpen ? (
<Menu.Item className={className} onClick={onOpen} {...pointer}>{content}</Menu.Item>
) : (
<Menu.LinkItem
className={className}
closeOnClick
render={<AppLink href={accountDialogHref(dialog)} onClick={leaveChat} />}
{...pointer}
>
{content}
</Menu.LinkItem>
);
}
return (
<Sidebar className="sidebar" aria-label="对话导航">
<SidebarHeader className="sidebar-header">
@@ -333,7 +362,11 @@ export function AppSidebar({
</SidebarContent>
<SidebarFooter className="sidebar-footer">
{controls && account ? (
{account ? (
/* One account menu on every page. On `/` Home controls it and its
entries open the dialogs in place; elsewhere it keeps its own open
state and the dialog entries go to `/?account=<dialog>`, where Home
opens that dialog (S2). Theme applies where the reader is. */
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
<Menu.Trigger
className="profile-trigger"
@@ -360,26 +393,24 @@ export function AppSidebar({
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
<UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
<Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" />
</Menu.Item>
<Menu.Item
className="account-menu-item"
onPointerEnter={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }}
onPointerDown={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }}
onClick={onOpenBilling}
>
<WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small>
</Menu.Item>
{accountEntry("profile", onOpenProfile, "account-menu-item", (
<><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("general", onOpenGeneral, "account-menu-item", (
<><Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("billing", onOpenBilling, "account-menu-item", (
<><WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small></>
), {
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
})}
<Menu.Separator className="account-menu-separator" />
<ThemePreferenceMenu />
<Menu.Separator className="account-menu-separator" />
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
<LogOut aria-hidden="true" /><span>退出登录</span>
</Menu.Item>
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
<><LogOut aria-hidden="true" /><span>退出登录</span></>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
@@ -391,20 +422,11 @@ export function AppSidebar({
{showExpandedContent ? <span><b>去登录</b></span> : null}
</a>
) : (
/* Read-only footer: the same 56px identity block, minus the chevron
and the menu. The account menu and its dialogs live on `/`, so this
goes there rather than growing a second copy of them. */
<AppLink
className="profile-trigger"
href="/"
aria-label={account ? `${account.name},打开账户` : "打开账户"}
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> : null}
</AppLink>
/* The account is still being read: the same 56px block, inert, with
no link and no spinner. It becomes the menu as soon as it lands. */
<div className="profile-trigger" aria-hidden="true">
<span className="profile-initial">·</span>
</div>
)}
</SidebarFooter>
<SidebarRail />
+32 -1
View File
@@ -2,7 +2,12 @@
import { useEffect, useRef } from "react";
import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types";
import {
ACCOUNT_DIALOG_QUERY_KEY,
parseAccountDialogIntent,
type AccountDialogIntent,
} from "@/lib/account-dialog-intent";
import type { ChartLibraryRecord, OpenAccountDialogOptions, Profile, SynastryRelationshipType } from "@/lib/home-types";
function stripQuery(key: string) {
const params = new URLSearchParams(window.location.search);
@@ -40,3 +45,29 @@ export function SynastryDeepLink(props: {
}, [props.draft, props.profile]);
return null;
}
/**
* `/?account=<dialog>` from the account menu on another page
* (TASK-self-edit-avatar-menu-20260928 S2). The intent is read once on mount
* and the parameter removed at once, so a reload or Back never reopens it;
* the dialog opens when Home's account is ready. No waiting state of its own.
*/
export function AccountDialogDeepLink(props: {
ready: boolean;
open: (dialog: AccountDialogIntent, options?: OpenAccountDialogOptions) => void;
}) {
const { ready, open } = props;
const intent = useRef<AccountDialogIntent | null | undefined>(undefined);
useEffect(() => {
if (intent.current === undefined) {
intent.current = parseAccountDialogIntent(window.location.search);
stripQuery(ACCOUNT_DIALOG_QUERY_KEY);
}
if (!intent.current || !ready) return;
const dialog = intent.current;
intent.current = null;
// Same source as the menu on `/` (use-home-shell-registration).
open(dialog, dialog === "billing" ? { source: "account-menu" } : undefined);
}, [ready, open]);
return null;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* The account menu on every page (TASK-self-edit-avatar-menu-20260928 S2).
*
* The account dialogs live on `/`. Off `/`, the same menu's dialog entries
* are links to `/?account=<dialog>`; Home opens that dialog once its account
* is ready and drops the parameter, so a reload or Back never opens it again.
* Theme needs no dialog and applies where the reader is.
*
* No dependencies: this ships with the shared sidebar.
*/
export const ACCOUNT_DIALOG_QUERY_KEY = "account";
export const ACCOUNT_DIALOG_INTENTS = ["profile", "general", "billing", "logout"] as const;
export type AccountDialogIntent = (typeof ACCOUNT_DIALOG_INTENTS)[number];
export function accountDialogHref(dialog: AccountDialogIntent): string {
return `/?${ACCOUNT_DIALOG_QUERY_KEY}=${dialog}`;
}
/** The dialog the URL asks for; null when absent or not one of the four. */
export function parseAccountDialogIntent(search: string): AccountDialogIntent | null {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const value = params.get(ACCOUNT_DIALOG_QUERY_KEY);
return (ACCOUNT_DIALOG_INTENTS as readonly string[]).includes(value ?? "") ? value as AccountDialogIntent : null;
}
/** The same query without the account intent (a malformed value is dropped too). */
export function searchWithoutAccountDialogIntent(search: string): string {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
params.delete(ACCOUNT_DIALOG_QUERY_KEY);
const query = params.toString();
return query ? `?${query}` : "";
}
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
import {
ACCOUNT_DIALOG_INTENTS,
accountDialogHref,
parseAccountDialogIntent,
searchWithoutAccountDialogIntent,
} from "../src/lib/account-dialog-intent.ts";
Object.assign(globalThis, { React });
/*
* TASK-self-edit-avatar-menu-20260928 S2: the avatar opens the same account
* menu on every page. Off `/` its dialog entries go to `/?account=<dialog>`,
* where Home opens that dialog once and drops the parameter.
*/
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
test("the account intent: four dialogs, anything else is ignored", () => {
assert.deepEqual([...ACCOUNT_DIALOG_INTENTS], ["profile", "general", "billing", "logout"]);
for (const dialog of ACCOUNT_DIALOG_INTENTS) {
assert.equal(accountDialogHref(dialog), `/?account=${dialog}`);
assert.equal(parseAccountDialogIntent(accountDialogHref(dialog).slice(1)), dialog);
}
assert.equal(parseAccountDialogIntent(""), null);
assert.equal(parseAccountDialogIntent("?account=chart-library"), null);
assert.equal(parseAccountDialogIntent("?account=admin"), null);
assert.equal(parseAccountDialogIntent("?new=1"), null);
assert.equal(searchWithoutAccountDialogIntent("?account=billing&new=1"), "?new=1");
assert.equal(searchWithoutAccountDialogIntent("?account=billing"), "");
});
test("the read-only sidebar renders the account menu's button, not a link home", async () => {
const { SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
const markup = renderToStaticMarkup(
<SidebarProvider>
<AppSidebar
sessions={[]}
activeSessionId={null}
account={{ name: "示例甲", email: "fictional@example.invalid", credits: 12, initial: "示", avatar: null }}
/>
</SidebarProvider>,
);
assert.match(markup, /<button[^>]*class="profile-trigger"[^>]*>/);
assert.doesNotMatch(markup, /<a[^>]*class="profile-trigger"[^>]*href="\/"/);
assert.match(markup, /示例甲/);
});
test("the four dialog entries open in place on `/` and link to `/?account=` elsewhere; theme stays in place", () => {
assert.match(sidebar, /accountEntry\("profile", onOpenProfile,/);
assert.match(sidebar, /accountEntry\("general", onOpenGeneral,/);
assert.match(sidebar, /accountEntry\("billing", onOpenBilling,/);
assert.match(sidebar, /accountEntry\("logout", onOpenLogout,/);
const entry = sidebar.slice(sidebar.indexOf("function accountEntry("), sidebar.indexOf(" return (\n <Sidebar"));
assert.match(entry, /return onOpen \? \(\s*<Menu\.Item className=\{className\} onClick=\{onOpen\}/);
assert.match(entry, /<Menu\.LinkItem[\s\S]*closeOnClick[\s\S]*render=\{<AppLink href=\{accountDialogHref\(dialog\)\} onClick=\{leaveChat\} \/>\}/);
// Theme is not a dialog: the same ThemePreferenceMenu on every page.
assert.equal(sidebar.match(/<ThemePreferenceMenu \/>/g)?.length, 1);
// Off `/` the menu keeps its own open state; on `/` Home controls it.
assert.match(sidebar, /const accountMenuOpen = controls \? controlledMenuOpen : ownMenuOpen;/);
// While the account is still being read: the same block, no link, no spinner.
assert.match(sidebar, /<div className="profile-trigger" aria-hidden="true">/);
});
test("Home opens the asked dialog once its account is ready, then drops the parameter", async () => {
const h = createClientLifecycleHarness();
const replaced: string[] = [];
const location = { pathname: "/", search: "?account=billing&new=1", hash: "" };
Object.assign(globalThis.window as object, {
location,
history: {
replaceState(_state: unknown, _title: string, url: string) {
replaced.push(url);
const next = new URL(url, "http://localhost");
location.search = next.search;
},
},
});
try {
const { AccountDialogDeepLink } = await import("../src/components/people-home-links.tsx");
const opened: Array<[string, unknown]> = [];
const open = (dialog: string, options?: unknown) => { opened.push([dialog, options]); };
await h.render(<AccountDialogDeepLink ready={false} open={open} />);
// Read and removed on mount, before the account is ready.
assert.deepEqual(replaced, ["/?new=1"]);
assert.equal(opened.length, 0);
await h.render(<AccountDialogDeepLink ready open={open} />);
assert.deepEqual(opened, [["billing", { source: "account-menu" }]]);
// Later renders never reopen it.
const again = () => { opened.push(["again", undefined]); };
await h.render(<AccountDialogDeepLink ready open={again} />);
assert.equal(opened.length, 1);
} finally {
await h.close();
}
});
test("an unknown value is dropped from the URL and opens nothing", async () => {
const h = createClientLifecycleHarness();
const replaced: string[] = [];
const location = { pathname: "/", search: "?account=admin", hash: "" };
Object.assign(globalThis.window as object, {
location,
history: { replaceState(_state: unknown, _title: string, url: string) { replaced.push(url); } },
});
try {
const { AccountDialogDeepLink } = await import("../src/components/people-home-links.tsx");
const opened: string[] = [];
await h.render(<AccountDialogDeepLink ready open={(dialog) => { opened.push(dialog); }} />);
assert.deepEqual(replaced, ["/"]);
assert.deepEqual(opened, []);
} finally {
await h.close();
}
});
test("Home wires it in one line and does not grow", () => {
assert.match(page, /import \{ AccountDialogDeepLink, SynastryDeepLink \} from "@\/components\/people-home-links";/);
assert.equal(page.match(/<AccountDialogDeepLink ready=\{hydrated && Boolean\(account\)\} open=\{openAccountDialog\} \/>/g)?.length, 1);
});
@@ -304,6 +304,20 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
await renderRoute();
return href;
}
/**
* An in-app link to `href` that is not on screen: what `AppLink` does on a
* plain click (note the target, then the client router renders and writes
* the address bar). The read-only footer was the only bare-`/` link; since
* TASK-self-edit-avatar-menu-20260928 S2 it is the account menu.
*/
async function followHref(href: string) {
const { noteClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts");
noteClientNavigationTarget(href);
routePath = new URL(href, "http://localhost").pathname;
pendingHref = href;
await renderRoute();
return href;
}
async function settle(until: () => boolean, label: string, ms = 12000) {
const started = Date.now();
while (Date.now() - started < ms) {
@@ -320,7 +334,7 @@ async function openDocument(tab: Tab, href: string, requests: string[]) {
}
}
}
return { h, view, link, followLink, settle, close, firstCommits, errors: h.errors };
return { h, view, link, followLink, followHref, settle, close, firstCommits, errors: h.errors };
}
type Doc = Awaited<ReturnType<typeof openDocument>>;
@@ -562,8 +576,11 @@ test("a warm return to bare / after a rectification session lands on the blank s
await doc.settle(() => doc!.view().rectificationSurface, "the rectification surface");
await doc.followLink("星盘");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
// The read-only footer links to bare `/`.
await doc.followLink("林遥,打开账户");
// 原值: `await doc.followLink("林遥,打开账户")`——只读页脚是回裸 `/` 的链接
// 新值: 页脚是账户菜单(不再是链接,下面断言);回裸 `/` 用等价的站内链接跳转 `followHref("/")`
// 原因: TASK-self-edit-avatar-menu-20260928 S2——各页头像都弹账户菜单;本测试锁的是「暖回裸 `/` 落空白首页」,与从哪个链接回来无关
assert.equal(doc.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), false);
await doc.followHref("/");
assert.equal(doc.firstCommits[1].loading, false);
assert.equal(doc.firstCommits[1].title, "新对话", "the blank starter home is the first frame");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
@@ -266,7 +266,10 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
return found;
};
const button = (label: string) => {
const found = h.elements().find((node) => node.tagName === "BUTTON" && node.text.includes(label));
// The sidebar footer is the account menu's button on every page now (S2)
// and carries the owner's name; page buttons are what these tests click.
const found = h.elements().find((node) => node.tagName === "BUTTON" && node.text.includes(label)
&& !String(node.props.className ?? "").split(/\s+/).includes("profile-trigger"));
assert.ok(found, `button ${label} is rendered`);
return found;
};
@@ -286,6 +289,18 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
await renderRoute();
return href;
}
/**
* An in-app link to `href` that is not on screen: what `AppLink` does on a
* plain click. The read-only footer was the only bare-`/` link; since
* TASK-self-edit-avatar-menu-20260928 S2 it is the account menu.
*/
async function followHref(href: string) {
const { noteClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts");
noteClientNavigationTarget(href);
tab.history.pushState(null, "", href);
await renderRoute();
return href;
}
async function settle(until: () => boolean, label: string, ms = 12000) {
const started = Date.now();
while (Date.now() - started < ms) {
@@ -309,7 +324,7 @@ async function openDocument(tab: Tab, href: string, options: { mobile?: boolean
await h.event(trigger);
assert.equal(drawerOpen(), true, "the mobile drawer opens");
}
return { h, view, link, button, followLink, settle, close, drawerOpen, openDrawer, errors: h.errors };
return { h, view, link, button, followLink, followHref, settle, close, drawerOpen, openDrawer, errors: h.errors };
}
async function openRectificationThenGoTo(tab: Tab, api: Api, secondaryLabel: string, options: { mobile?: boolean } = {}) {
@@ -431,10 +446,13 @@ test("a stashed rectification session of the person you left is not reopened as
await doc.close();
doc = undefined;
doc = await openDocument(tab, "/chart");
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), "the read-only account link");
// 原值: 等只读页脚链接「林遥,打开账户」出现,再 `followLink` 它回裸 `/`
// 新值: 等页脚账户菜单按钮出现(且不再是链接),用等价的站内链接 `followHref("/")` 回裸 `/`
// 原因: TASK-self-edit-avatar-menu-20260928 S2——各页头像都弹账户菜单;本测试锁的是「回裸 `/` 不重开别人的校正」,与从哪个链接回来无关
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "BUTTON" && String(node.props.className ?? "").includes("profile-trigger")), "the account menu trigger");
assert.equal(doc.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), false);
const opensBefore = api.opens();
// Read-only footer: the account block links to bare `/`.
const href = await doc.followLink("林遥,打开账户");
const href = await doc.followHref("/");
assert.equal(href, "/");
await doc.settle(() => doc!.view().title !== null, "home to reveal");
for (let i = 0; i < 10; i += 1) await doc.h.idle();
+8 -3
View File
@@ -242,12 +242,17 @@ test("the same component renders read-only when `/` is not the one mounting it",
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
// Only the new-chat action carries intent; the footer remains a home link.
// Only the new-chat action carries intent.
assert.match(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href=\{newChatHref\(\)\}[^>]*onClick=\{closeDrawer\}/);
assert.doesNotMatch(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href="\/"/);
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
assert.match(appSidebar, /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/);
// 原值: /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/——只读页脚是回 `/` 的链接,无 chevron、无菜单
// 新值: 页脚在两种模式都是同一个 `Menu.Root`(`.profile-trigger` 按钮),不再有回 `/` 的页脚链接;只读模式的弹窗项是 `/?account=<dialog>` 链接
// 原因: TASK-self-edit-avatar-menu-20260928 S2——产品要求各页点头像都弹账户菜单(推翻侧栏统一单「次级页头像 = 回首页链接」)
assert.doesNotMatch(appSidebar, /<AppLink\n\s*className="profile-trigger"/);
assert.equal(appSidebar.match(/<Menu\.Root /g)?.length, 1, "one account menu");
assert.match(appSidebar, /\{account \? \(\s*(?:\/\*[\s\S]*?\*\/\s*)?<Menu\.Root open=\{accountMenuOpen\}/);
assert.match(appSidebar, /render=\{<AppLink href=\{accountDialogHref\(dialog\)\}/);
// And nothing in read-only mode can write: the write callbacks only exist
// inside `controls`, which the secondary layout never passes.