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
+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}` : "";
}