fix(web): 四个页面共用一份会话列表,空会话不入列
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m33s

对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
This commit is contained in:
jesse-ux
2026-09-17 21:27:40 +08:00
parent 5203f9f0c3
commit e4e73f56c0
76 changed files with 1209 additions and 627 deletions
+13 -15
View File
@@ -5,6 +5,7 @@ import {
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
} from "./consultation-entrypoint.ts";
import { shanghaiDateParts } from "./session-shanghai-clock.ts";
export type ReplyTheme = ConsultationDomain;
@@ -64,10 +65,14 @@ export type SessionTitleOptions = {
readonly existingTitles?: readonly string[];
};
const DATED_CATEGORY_PREFIX = /^(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日/;
const DATED_CATEGORY_SUFFIX = /^\d{1,2}月\d{1,2}日\s*·\s*(?:生时校正|今日节奏)(?:\s+\d{2}:\d{2})?$/;
export function isGenericSessionTitle(title: string): boolean {
const text = title.replace(/\s+/g, " ").trim();
if (!text) return true;
if (GENERIC_SESSION_TITLES.has(text)) return true;
if (DATED_CATEGORY_PREFIX.test(text) || DATED_CATEGORY_SUFFIX.test(text)) return true;
return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text);
}
@@ -76,15 +81,9 @@ function clipTitle(value: string, maxChars = 14): string {
return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value;
}
function datedSessionTitle(at: Date, suffix: string): string {
return `${at.getMonth() + 1}月${at.getDate()}日 · ${suffix}`;
}
function uniquifySessionTitle(title: string, existingTitles: readonly string[], at: Date): string {
if (!existingTitles.includes(title)) return title;
const hours = String(at.getHours()).padStart(2, "0");
const minutes = String(at.getMinutes()).padStart(2, "0");
return `${title} ${hours}:${minutes}`;
function datedSessionTitle(at: Date, category: string): string {
const { month, day } = shanghaiDateParts(at);
return `${category} · ${month}月${day}日`;
}
export function resolveSessionTitle(
@@ -93,21 +92,20 @@ export function resolveSessionTitle(
options: SessionTitleOptions = {},
): string {
const at = options.at ?? new Date(); // new sessions only; see SessionTitleOptions.at
const existingTitles = options.existingTitles ?? [];
if (modelTitle && !isGenericSessionTitle(modelTitle)) {
return uniquifySessionTitle(clipTitle(modelTitle), existingTitles, at);
return clipTitle(modelTitle);
}
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
return uniquifySessionTitle(datedSessionTitle(at, "今日节奏"), existingTitles, at);
return datedSessionTitle(at, "今日节奏");
}
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
return uniquifySessionTitle(datedSessionTitle(at, "生时校正"), existingTitles, at);
return datedSessionTitle(at, "生时校正");
}
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
if (!normalized) return "新对话";
if (options.theme && options.theme !== "general") {
const label = consultationDomainDefinition(options.theme).label;
return uniquifySessionTitle(`${label} · ${clipTitle(normalized, 10)}`, existingTitles, at);
return `${label} · ${clipTitle(normalized, 10)}`;
}
return uniquifySessionTitle(clipTitle(normalized), existingTitles, at);
return clipTitle(normalized);
}
@@ -10,6 +10,8 @@ type QueryResult = Readonly<{
count?: number | null;
}>;
type CmpOp = "eq" | "neq" | "lt" | "gt" | "lte" | "gte";
type Filter =
| Readonly<{ kind: "eq"; column: string; value: unknown }>
| Readonly<{ kind: "neq"; column: string; value: unknown }>
@@ -18,7 +20,12 @@ type Filter =
| Readonly<{ kind: "like"; column: string; value: unknown }>
| Readonly<{ kind: "in"; column: string; value: readonly unknown[] }>
| Readonly<{ kind: "is"; column: string; value: unknown }>
| Readonly<{ kind: "notContains"; column: string; value: unknown }>;
| Readonly<{ kind: "notContains"; column: string; value: unknown }>
| Readonly<{ kind: "or"; expression: string }>;
export type PostgrestOrNode =
| Readonly<{ kind: "cmp"; column: string; op: CmpOp; value: unknown }>
| Readonly<{ kind: "and"; nodes: readonly PostgrestOrNode[] }>;
type Mutation =
| Readonly<{ kind: "insert"; rows: readonly Record<string, unknown>[] }>
@@ -48,6 +55,93 @@ export function formatOrderClause(
.join(", ")}`;
}
const SQL_CMP: Record<CmpOp, string> = {
eq: "=",
neq: "<>",
lt: "<",
gt: ">",
lte: "<=",
gte: ">=",
};
function splitTopLevel(expression: string): string[] {
const parts: string[] = [];
let depth = 0;
let inString = false;
let start = 0;
for (let index = 0; index < expression.length; index += 1) {
const char = expression[index];
if (char === "\"" && expression[index - 1] !== "\\") inString = !inString;
if (inString) continue;
if (char === "(") depth += 1;
else if (char === ")") depth -= 1;
else if (char === "," && depth === 0) {
parts.push(expression.slice(start, index));
start = index + 1;
}
}
parts.push(expression.slice(start));
return parts.map((part) => part.trim()).filter(Boolean);
}
function parsePostgrestValue(raw: string): unknown {
const trimmed = raw.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "null") return null;
if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) {
return JSON.parse(trimmed) as unknown;
}
return trimmed;
}
function parsePostgrestTerm(term: string): PostgrestOrNode {
const andMatch = /^and\(([\s\S]*)\)$/.exec(term);
if (andMatch?.[1] != null) {
return { kind: "and", nodes: splitTopLevel(andMatch[1]).map(parsePostgrestTerm) };
}
const cmp = /^([a-z_][a-z0-9_]*)\.(eq|neq|lt|gt|lte|gte)\.([\s\S]*)$/.exec(term);
if (!cmp) throw new Error(`unsupported or filter: ${term}`);
return {
kind: "cmp",
column: cmp[1] ?? "",
op: (cmp[2] ?? "eq") as CmpOp,
value: parsePostgrestValue(cmp[3] ?? ""),
};
}
export function parsePostgrestOr(expression: string): PostgrestOrNode[] {
return splitTopLevel(expression).map(parsePostgrestTerm);
}
function compilePostgrestNode(
node: PostgrestOrNode,
parameters: unknown[],
types: Map<string, string>,
): string {
if (node.kind === "and") {
return `(${node.nodes.map((child) => compilePostgrestNode(child, parameters, types)).join(" and ")})`;
}
identifier(node.column);
parameters.push(databaseValue(types.get(node.column), node.value));
return `${identifier(node.column)} ${SQL_CMP[node.op]} $${parameters.length}`;
}
export function compilePostgrestOr(
expression: string,
parameters: unknown[],
types: Map<string, string>,
): string {
const nodes = parsePostgrestOr(expression);
if (nodes.length === 0) return "true";
if (nodes.length === 1) {
const only = nodes[0];
if (!only) return "true";
return compilePostgrestNode(only, parameters, types);
}
return `(${nodes.map((node) => compilePostgrestNode(node, parameters, types)).join(" or ")})`;
}
export function upsertConflictColumns(options?: { onConflict?: string }): string[] {
return (options?.onConflict ?? "")
.split(",")
@@ -290,11 +384,21 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
not(column: string, operator: string, value: unknown) {
identifier(column);
if (operator === "eq") {
this.filters.push({ kind: "neq", column, value });
return this;
}
if (operator !== "cs") throw new Error("unsupported not filter");
this.filters.push({ kind: "notContains", column, value });
return this;
}
or(expression: string) {
if (!expression.trim()) throw new Error("empty or filter");
this.filters.push({ kind: "or", expression });
return this;
}
order(column: string, options: { ascending?: boolean } = {}) {
identifier(column);
this.ordering.push({ column, ascending: options.ascending !== false });
@@ -342,6 +446,9 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
): string {
if (this.filters.length === 0) return "";
const parts = this.filters.map((filter) => {
if (filter.kind === "or") {
return compilePostgrestOr(filter.expression, parameters, types);
}
const column = identifier(filter.column);
if (filter.kind === "is") {
if (filter.value === null) return `${column} is null`;
+26 -3
View File
@@ -79,6 +79,7 @@ export function createSession(
theme: "general",
modelId,
messages: [],
createdAt: timestamp(),
updatedAt: timestamp(),
sessionType,
rectificationCaseId: null,
@@ -247,18 +248,30 @@ export async function fetchDailyStarlanguage(signal: AbortSignal): Promise<Daily
export type SessionListPage = {
readonly sessions: unknown;
readonly nextCursor: string | null;
readonly draft: unknown;
};
export function readSessionListPage(value: unknown): SessionListPage {
if (Array.isArray(value)) return { sessions: value, nextCursor: null };
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null };
const page = value as { sessions?: unknown; nextCursor?: unknown };
if (Array.isArray(value)) return { sessions: value, nextCursor: null, draft: null };
if (!value || typeof value !== "object") return { sessions: [], nextCursor: null, draft: null };
const page = value as { sessions?: unknown; nextCursor?: unknown; draft?: unknown };
return {
sessions: page.sessions,
nextCursor: typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null,
draft: page.draft ?? null,
};
}
export function readDraftConsultation(
value: unknown,
catalog: PublicLanguageModelCatalog | null,
): ChatSession | null {
if (!value || typeof value !== "object") return null;
const parsed = readSessions([{ ...(value as object), messages: [] }], catalog).sessions[0];
if (!parsed || parsed.sessionType !== "consultation") return null;
return { ...parsed, messages: [], messagesHydrated: true };
}
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
const fallbackSessionIds: string[] = [];
@@ -271,6 +284,7 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
chart_profile_name?: unknown;
chart_profile_role?: unknown;
session_type?: unknown;
created_at?: unknown;
updated_at?: unknown;
archived_at?: unknown;
};
@@ -327,6 +341,15 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
: typeof session.archivedAt === "string" && session.archivedAt
? session.archivedAt
: null,
createdAt: typeof session.createdAt === "number"
? session.createdAt
: typeof session.created_at === "string"
? Date.parse(session.created_at)
: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
? Date.parse(session.updated_at)
: timestamp(),
updatedAt: typeof session.updatedAt === "number"
? session.updatedAt
: typeof session.updated_at === "string"
-9
View File
@@ -103,15 +103,6 @@ export function sessionChartLabel(session: ChatSession, library: readonly ChartL
return current ? name : `资料已删除 · ${name}`;
}
export function sessionSidebarTitle(session: ChatSession, _library?: readonly ChartLibraryRecord[]) {
return session.title?.trim() || "新对话";
}
export function sessionSidebarSubtitle(session: ChatSession, library: readonly ChartLibraryRecord[]) {
if (session.chartProfileRole === "self" || !session.chartProfileId) return null;
return sessionChartLabel(session, library);
}
export function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
const others = library.filter((record) => record.role !== "self");
+1
View File
@@ -79,6 +79,7 @@ export type ChatSession = {
theme: Theme;
modelId: string;
messages: Message[];
createdAt?: number;
updatedAt: number;
sessionType: ChatSessionType;
rectificationCaseId: string | null;
+203
View File
@@ -0,0 +1,203 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
import { readSessions } from "@/lib/home-cloud-sync";
import type { Account, ChatSession } from "@/lib/home-types";
import { SESSION_PAGE_SIZE } from "@/lib/session-cursor";
import { isListedSidebarSession } from "@/lib/session-list-filter";
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
export type SessionListBoot = {
readonly sessions: ChatSession[];
readonly rawRows: unknown;
readonly draftRow: unknown;
readonly cursor: string | null;
readonly account: Account | null;
readonly signedOut: boolean;
};
export type ShellRegistration = {
readonly controls: AppSidebarControls;
readonly escapeBlocked: boolean;
readonly activeSessionId: string | null;
readonly openingSessionId: string | null;
readonly openErrorSessionId: string | null;
readonly openErrorMessage: string;
readonly insetClassName: string;
readonly insetInert: boolean;
readonly sidebarSessions?: readonly SidebarSession[];
readonly sidebarAccount?: SidebarAccount;
};
export type SessionListContextValue = {
sessions: ChatSession[];
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
sessionsCursor: string | null;
setSessionsCursor: Dispatch<SetStateAction<string | null>>;
account: Account | null;
setAccount: Dispatch<SetStateAction<Account | null>>;
signedOut: boolean;
settled: boolean;
ready: Promise<void>;
boot: () => SessionListBoot | null;
registerShellControls: (registration: ShellRegistration | null) => void;
registration: ShellRegistration | null;
};
const SessionListContext = createContext<SessionListContextValue | null>(null);
function createReadyGate(): { promise: Promise<void>; resolve: () => void } {
let settle = () => {};
const promise = new Promise<void>((resolve) => {
settle = resolve;
});
return { promise, resolve: () => settle() };
}
function toSidebarAccount(account: Account | null): SidebarAccount | null {
if (!account) return null;
const profileName = account.profile && typeof account.profile === "object" && "name" in account.profile
&& typeof account.profile.name === "string" ? account.profile.name.trim() : "";
const name = profileName;
const email = account.user.email || "";
return {
name: name || email || "账户",
email,
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: account.credits,
avatar: account.avatar,
};
}
function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] {
return sessions.filter(isListedSidebarSession).map((session) => toSidebarSessionRow(session));
}
async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
const [sessionResponse, accountResponse] = await Promise.all([
fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}`, { signal, cache: "no-store" }),
fetch("/api/account", { signal, cache: "no-store" }),
]);
if (sessionResponse.status === 401 || accountResponse.status === 401) {
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: true };
}
const sessionPayload = await sessionResponse.json().catch(() => null) as {
sessions?: unknown;
nextCursor?: unknown;
draft?: unknown;
} | null;
const accountPayload = await accountResponse.json().catch(() => null);
if (!sessionResponse.ok || !accountResponse.ok) {
return { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
}
const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : [];
const parsed = readSessions(rawRows, null);
const cursor = typeof sessionPayload?.nextCursor === "string" ? sessionPayload.nextCursor : null;
return {
sessions: parsed.sessions,
rawRows,
draftRow: sessionPayload?.draft ?? null,
cursor,
account: accountPayload as Account,
signedOut: false,
};
}
export function SessionListProvider({ children }: { children: ReactNode }) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const [account, setAccount] = useState<Account | null>(null);
const [signedOut, setSignedOut] = useState(false);
const [settled, setSettled] = useState(false);
const [registration, setRegistration] = useState<ShellRegistration | null>(null);
const bootRef = useRef<SessionListBoot | null>(null);
const [readyPack] = useState(createReadyGate);
useEffect(() => {
const controller = new AbortController();
void loadSessionList(controller.signal)
.then((boot) => {
if (controller.signal.aborted) return;
bootRef.current = boot;
setSessions(boot.sessions);
setSessionsCursor(boot.cursor);
setAccount(boot.account);
setSignedOut(boot.signedOut);
setSettled(true);
readyPack.resolve();
})
.catch(() => {
if (controller.signal.aborted) return;
bootRef.current = { sessions: [], rawRows: [], draftRow: null, cursor: null, account: null, signedOut: false };
setSettled(true);
readyPack.resolve();
});
return () => controller.abort();
}, [readyPack]);
const registerShellControls = useCallback((next: ShellRegistration | null) => {
setRegistration(next);
}, []);
const boot = useCallback(() => bootRef.current, []);
const value = useMemo<SessionListContextValue>(() => ({
sessions,
setSessions,
sessionsCursor,
setSessionsCursor,
account,
setAccount,
signedOut,
settled,
ready: readyPack.promise,
boot,
registerShellControls,
registration,
}), [
account,
boot,
readyPack.promise,
registerShellControls,
registration,
sessions,
sessionsCursor,
settled,
signedOut,
]);
return <SessionListContext.Provider value={value}>{children}</SessionListContext.Provider>;
}
export function useSessionList(): SessionListContextValue {
const value = useContext(SessionListContext);
if (!value) {
throw new Error("useSessionList must be used inside SessionListProvider");
}
return value;
}
export function sessionListSidebarModel(list: SessionListContextValue): {
sessions: readonly SidebarSession[];
account: SidebarAccount | null;
} {
const registered = list.registration;
return {
sessions: registered?.sidebarSessions ?? sessionsToSidebarRows(list.sessions),
account: registered?.sidebarAccount ?? toSidebarAccount(list.account),
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { ChatSession } from "@/lib/home-types";
export function findReusableEmptyConsultation(
sessions: readonly ChatSession[],
): ChatSession | undefined {
return sessions.find((session) => (
session.sessionType === "consultation"
&& !session.archivedAt
&& session.messagesHydrated
&& session.messages.length === 0
));
}
export function isListedSidebarSession(session: ChatSession): boolean {
if (session.archivedAt) return false;
if (session.sessionType === "birth_time_rectification") return true;
if (!session.messagesHydrated) return true;
return session.messages.length > 0;
}
@@ -0,0 +1,41 @@
/** Product UI dates for session titles and subtitles are wall-clock in China. */
export const SESSION_CLOCK_TIMEZONE = "Asia/Shanghai";
function part(
parts: Intl.DateTimeFormatPart[],
type: Intl.DateTimeFormatPartTypes,
): string {
return parts.find((item) => item.type === type)?.value ?? "";
}
export function shanghaiDateParts(at: Date, timeZone = SESSION_CLOCK_TIMEZONE): {
month: number;
day: number;
} {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
month: "numeric",
day: "numeric",
}).formatToParts(at);
return {
month: Number(part(parts, "month")),
day: Number(part(parts, "day")),
};
}
export function shanghaiDateTimeLabel(at: Date, timeZone = SESSION_CLOCK_TIMEZONE): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(at);
const month = Number(part(parts, "month"));
const day = Number(part(parts, "day"));
const hour = part(parts, "hour").padStart(2, "0");
const minute = part(parts, "minute").padStart(2, "0");
return `${month}月${day}日 ${hour}:${minute}`;
}
+36
View File
@@ -0,0 +1,36 @@
import type { SidebarSession } from "@/components/sidebar-session-row";
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
import { sessionChartLabel } from "@/lib/home-profile";
import { shanghaiDateTimeLabel } from "@/lib/session-shanghai-clock";
export function sessionSidebarTitle(session: ChatSession) {
return session.title?.trim() || "新对话";
}
export function sessionSidebarSubtitle(
session: ChatSession,
library: readonly ChartLibraryRecord[] = [],
): string {
const created = session.createdAt || session.updatedAt;
const clock = Number.isFinite(created) ? shanghaiDateTimeLabel(new Date(created)) : "";
const parts: string[] = [];
if (clock) parts.push(clock);
if (session.chartProfileRole && session.chartProfileRole !== "self" && session.chartProfileId) {
parts.push(sessionChartLabel(session, library));
}
return parts.join(" · ");
}
export function toSidebarSessionRow(
session: ChatSession,
library: readonly ChartLibraryRecord[] = [],
): SidebarSession {
return {
id: session.id,
title: sessionSidebarTitle(session, library),
subtitle: sessionSidebarSubtitle(session, library) || null,
pinned: session.pinned,
archived: Boolean(session.archivedAt),
updatedAt: session.updatedAt,
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ function clipHan(value: string, maxChars: number): string {
}
const BIRTH_STAMP = /\d{4}年\d{1,2}月(?:\d{1,2}日)?|\d{1,2}:\d{2}/;
const DATED_ENTRY_TITLE = /^\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正)$/;
const DATED_ENTRY_TITLE = /^(?:(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正))(?:\s+\d{2}:\d{2})?$/;
export function sanitizeSessionTitle(raw: string): string | null {
if (/[\r\n]/.test(raw)) return null;
-58
View File
@@ -1,58 +0,0 @@
import type { SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
/**
* The session list and account the read-only sidebar shows, kept in memory for
* one tab.
*
* `/chart`, `/ephemeris` and `/reports` used to re-issue `GET /api/sessions` and
* `GET /api/account` on every arrival, because each page mounted its own shell.
* The shared `(secondary)` layout removes the per-page remount; this removes the
* repeat when the reader leaves for `/` and comes back. Nothing durable is
* written: a stale session list surviving a browser restart is worse than one
* fetch, and it would outlive a sign-out.
*
* Keyed by account id so a second account in the same tab never reads the
* first one's rows. The pointer is what makes a synchronous read possible: the
* account id only arrives with the payload, so the reader cannot name its own
* key before the first fetch has happened.
*/
export const SIDEBAR_CACHE_TTL_MS = 60_000;
export type SidebarCacheEntry = {
readonly accountId: string;
readonly sessions: readonly SidebarSession[];
readonly account: SidebarAccount | null;
readonly fetchedAt: number;
};
const entries = new Map<string, SidebarCacheEntry>();
let currentAccountId: string | null = null;
export function readSidebarCache(): SidebarCacheEntry | null {
if (currentAccountId === null) return null;
return entries.get(currentAccountId) ?? null;
}
export function writeSidebarCache(entry: SidebarCacheEntry): void {
entries.set(entry.accountId, entry);
currentAccountId = entry.accountId;
}
/** True when the entry may be shown without going back to the network. */
export function sidebarCacheIsFresh(entry: SidebarCacheEntry | null, now: number): boolean {
if (entry === null) return false;
const age = now - entry.fetchedAt;
return age >= 0 && age < SIDEBAR_CACHE_TTL_MS;
}
/**
* Called from `/` after every session write — create, rename, delete, archive,
* pin — and on any 401. Renaming a session and walking to `/chart` has to show
* the new title, and a signed-out tab must not keep a list on screen.
*/
export function invalidateSidebarCache(): void {
entries.clear();
currentAccountId = null;
}