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:
co-authored by
Claude Opus 5.5
parent
8415fdca72
commit
c15def0c50
+25
-11
@@ -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` 是新建对话;其余——登录后、手输网址、书签、刷新裸 `/`——一律是当前人物的空白首页(开场问候 + 输入框),已有一个没发过消息的新对话就复用它。不再记「上次在哪条对话」:离开首页、登录过期都不存任何返回记录,后台还在恢复的旧回答也不把空白首页切走。
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user