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
@@ -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.