fix: keep western labels near their planets and chats on the current person
Independent Staging Quality Gate / validate (push) Failing after 6m46s
Independent Staging Quality Gate / publish (push) Skipped

Cap planet captions at 30 degrees with a third ring, fall back only within the current person's sessions, and load self when the people catalog fails.
This commit is contained in:
jesse-ux
2026-09-25 23:02:17 +08:00
parent b8712dad50
commit d08ffdc0a6
28 changed files with 538 additions and 545 deletions
@@ -3,7 +3,7 @@ import { isConsultationSubjectProfileComplete } from "@/lib/consultation-subject
import { sessionChartLabel } from "@/lib/home-profile";
import type { ChartLibraryRecord, ChatSession } from "@/lib/home-types";
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed";
export type ChatProfileCatalogStatus = "idle" | "loading" | "ready" | "failed" | "degraded";
/** New ordinary chats follow the current person, not a fixed self id. */
export function NEW_CHAT_SUBJECT_ID(): string {
@@ -91,7 +91,7 @@ export function chatProfilePickerRows(input: {
library: readonly ChartLibraryRecord[];
selectedId: string;
}): ChatProfilePickerRow[] {
if (input.status !== "ready") return [];
if (input.status !== "ready" && input.status !== "degraded") return [];
const self = input.library.find((record) => record.role === "self" || record.id === "self");
const others = input.library.filter((record) => record.role === "other" && record.id !== "self");
const rows: ChatProfilePickerRow[] = [];
@@ -106,6 +106,7 @@ export function chatProfilePickerRows(input: {
selected: input.selectedId === "self",
});
}
if (input.status === "degraded") return rows;
for (const record of others) rows.push(toRow(record, input.selectedId));
return rows;
}
+88 -10
View File
@@ -2,9 +2,9 @@
import { useEffect, useSyncExternalStore } from "react";
import { fetchAccount, fetchCloudChartLibrary } from "@/lib/home-cloud-sync";
import { fetchCloudChartLibrary } from "@/lib/home-cloud-sync";
import { readProfile, upsertSelfChart } from "@/lib/home-profile";
import type { ChartLibraryRecord, Profile } from "@/lib/home-types";
import type { Account, ChartLibraryRecord, Profile } from "@/lib/home-types";
const SELF_SUBJECT_ID = "self";
export const LAST_SUBJECT_ACCOUNT_KEY = "jyotisha_current_subject_account";
@@ -131,6 +131,40 @@ export function useCurrentSubjectProfile(self: Profile): Profile | null {
let catalogRequest: Promise<ChartLibraryRecord[]> | null = null;
let catalogAccountId = "";
let catalogGeneration = 0;
let othersUnavailable = false;
let accountFlight: Promise<{ status: number; account: Account | null }> | null = null;
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
export function subjectCatalogOthersUnavailable(): boolean {
return othersUnavailable;
}
export function invalidateAccountFlight(): void {
accountFlight = null;
}
/** One in-flight account read shared by the session list and the person catalog. */
export function readBootAccount(): Promise<{ status: number; account: Account | null }> {
if (accountFlight) return accountFlight;
const published = (async () => {
const response = await fetch("/api/account", { cache: "no-store" });
if (response.status === 401) return { status: 401, account: null };
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error("account_unavailable");
return { status: response.status, account: payload as Account };
})().catch((error: unknown) => {
if (accountFlight === published) accountFlight = null;
throw error;
});
accountFlight = published;
return published;
}
function clearCatalogRecovery(): void {
if (recoveryTimer === null) return;
clearTimeout(recoveryTimer);
recoveryTimer = null;
}
export function resetSubjectCatalogForTests(): void {
catalogGeneration += 1;
@@ -140,6 +174,9 @@ export function resetSubjectCatalogForTests(): void {
subjectId = SELF_SUBJECT_ID;
catalogReady = false;
catalog = null;
othersUnavailable = false;
accountFlight = null;
clearCatalogRecovery();
}
export function invalidateSubjectCatalog(): void {
@@ -148,21 +185,62 @@ export function invalidateSubjectCatalog(): void {
catalogAccountId = "";
catalogReady = false;
catalog = null;
othersUnavailable = false;
clearCatalogRecovery();
emitCatalog();
}
function rememberCatalog(account: Account, library: ChartLibraryRecord[], degraded: boolean): ChartLibraryRecord[] {
catalogAccountId = account.user.id;
catalog = library;
othersUnavailable = degraded;
bindCurrentSubjectAccount(catalogAccountId, degraded ? [] : library.filter((row) => row.role === "other").map((row) => row.id));
emitCatalog();
return library;
}
function scheduleCatalogRecovery(account: Account): void {
if (recoveryTimer !== null || !othersUnavailable) return;
const generation = catalogGeneration;
const timer = setTimeout(() => {
recoveryTimer = null;
if (generation !== catalogGeneration || !othersUnavailable) return;
void fetchCloudChartLibrary().then((others) => {
if (generation !== catalogGeneration) return;
const self = readProfile(account.profile);
const library = upsertSelfChart(others.filter((row) => row.role === "other"), self);
clearCatalogRecovery();
catalogRequest = Promise.resolve(rememberCatalog(account, library, false));
}).catch(() => {
if (generation === catalogGeneration && othersUnavailable) scheduleCatalogRecovery(account);
});
}, 3000);
if (typeof timer === "object" && timer && "unref" in timer) timer.unref();
recoveryTimer = timer;
}
export function loadSubjectCatalog(force = false): Promise<ChartLibraryRecord[]> {
if (!force && catalogRequest) return catalogRequest;
const generation = ++catalogGeneration;
catalogRequest = Promise.all([fetchAccount(), fetchCloudChartLibrary()]).then(([account, others]) => {
clearCatalogRecovery();
catalogRequest = readBootAccount().then(async (result) => {
if (generation !== catalogGeneration) return loadSubjectCatalog();
const self = readProfile(account.profile);
const library = upsertSelfChart(others.filter((row) => row.role === "other"), self);
catalogAccountId = account.user.id;
catalog = library;
bindCurrentSubjectAccount(catalogAccountId, library.filter((row) => row.role === "other").map((row) => row.id));
emitCatalog();
return library;
if (result.status === 401 || !result.account) throw new Error("account_unavailable");
const account = result.account;
try {
const others = await fetchCloudChartLibrary();
if (generation !== catalogGeneration) return loadSubjectCatalog();
const self = readProfile(account.profile);
const library = upsertSelfChart(others.filter((row) => row.role === "other"), self);
return rememberCatalog(account, library, false);
} catch (error) {
if (generation !== catalogGeneration) throw error;
const self = readProfile(account.profile);
const library = upsertSelfChart([], self);
const remembered = rememberCatalog(account, library, true);
scheduleCatalogRecovery(account);
return remembered;
}
}).catch((error: unknown) => {
if (generation === catalogGeneration) catalogRequest = null;
throw error;
+16 -11
View File
@@ -15,7 +15,7 @@ import {
import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
import { bindCurrentSubjectAccount, invalidateSubjectCatalog, loadSubjectCatalog, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { readSessions } from "@/lib/home-cloud-sync";
import type { Account, ChatSession } from "@/lib/home-types";
@@ -97,20 +97,21 @@ function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession
&& sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session));
}
async function loadSessionList(signal: AbortSignal): Promise<SessionListBoot> {
const accountResponse = await fetch("/api/account", { signal, cache: "no-store" });
if (accountResponse.status === 401) {
async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promise<SessionListBoot> {
const accountResult = await readBootAccount();
signal.throwIfAborted();
if (accountResult.status === 401) {
invalidateSubjectCatalog();
bindCurrentSubjectAccount("", null);
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
}
if (!accountResponse.ok) throw new Error("account_unavailable");
const accountPayload = await accountResponse.json() as Account;
if (!accountResult.account) throw new Error("account_unavailable");
const accountPayload = accountResult.account;
if (readCurrentSubjectAccountId() !== accountPayload.user.id) {
invalidateSubjectCatalog();
bindCurrentSubjectAccount(accountPayload.user.id, null);
}
await loadSubjectCatalog();
await loadSubjectCatalog(forceCatalog);
signal.throwIfAborted();
const subjectId = readCurrentSubjectId();
const sessionResponse = await fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" });
@@ -152,21 +153,24 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
useEffect(() => {
let disposed = false;
let displayedAccount = "";
const load = (nextReadyPack: ReturnType<typeof createReadyGate>) => {
const load = (nextReadyPack: ReturnType<typeof createReadyGate>, forceCatalog = false) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setSettled(false);
setSignedOut(false);
bootRef.current = null;
void loadSessionList(controller.signal)
void loadSessionList(controller.signal, forceCatalog)
.then((boot) => {
if (disposed || controller.signal.aborted || controllerRef.current !== controller) return;
const requestedSubject = boot.subjectId ?? readCurrentSubjectId();
if (requestedSubject !== readCurrentSubjectId()) return;
bootRef.current = boot;
const sameAccount = Boolean(boot.account) && displayedAccount === boot.account?.user.id;
setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions) : boot.sessions);
setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions, {
subjectId: requestedSubject,
complete: boot.cursor == null,
}) : boot.sessions);
displayedAccount = boot.account?.user.id ?? "";
setSessionsCursor(boot.cursor);
setAccount(boot.account);
@@ -186,7 +190,8 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
const next = createReadyGate();
currentReady.current = next;
previous.resolve();
load(next);
invalidateAccountFlight();
load(next, true);
};
load(readyPack);
const unsubscribe = subscribeCurrentSubject(() => {
+14 -2
View File
@@ -1,11 +1,23 @@
import { sessionMatchesSubject } from "./chat-profile-picker-model";
import type { ChatSession } from "./home-types";
import { isUnsavedEmptyConsultation } from "./session-list-filter";
/** A list row is metadata, never authority to erase an opened transcript. */
/** A list row is metadata, never authority to erase an opened transcript.
* A complete refresh of one person drops that person's rows the server no
* longer returns, and leaves every other person untouched. */
export function mergeSubjectSessionRows(
current: readonly ChatSession[],
incoming: readonly ChatSession[],
options?: { readonly subjectId?: string; readonly complete?: boolean },
): ChatSession[] {
const rows = new Map(current.map((session) => [session.id, session]));
const incomingIds = new Set(incoming.map((session) => session.id));
const subjectId = options?.subjectId;
const base = options?.complete && subjectId
? current.filter((session) => incomingIds.has(session.id)
|| !sessionMatchesSubject(session, subjectId)
|| isUnsavedEmptyConsultation(session))
: current;
const rows = new Map(base.map((session) => [session.id, session]));
for (const row of incoming) {
const existing = rows.get(row.id);
rows.set(row.id, existing?.messagesHydrated