/** * @file chatUi.ts — whether a Claude/Codex session shows as a chat * (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its * own view (`useSessionChat`, toggled by `setSessionChat` from the pane's * chat/terminal button), kept in this browser's localStorage * (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI * setting is only the DEFAULT: kept by the server per account * (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one * account), it is written onto a session the first time that session is * shown, so changing it later leaves every session already seen alone. Read * once per page load and shared through `useChatUi`; `saveChatUi` updates * every reader at once and puts the old value back if the save fails. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useSyncExternalStore } from "react"; import { api } from "./api"; export const CHAT_UI_KEY = "agenthub.chatUi"; export const VIEWS_KEY = "agenthub.chatUi.sessions"; const MAX_VIEWS = 200; let chatUi = false; let loaded = false; let loading: Promise | null = null; const listeners = new Set<() => void>(); const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; }; const emit = (next: boolean) => { chatUi = next; listeners.forEach((fn) => fn()); }; function readViews(): Record { try { const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}"); return v && typeof v === "object" ? (v as Record) : {}; } catch { return {}; } } let views = readViews(); function load() { // A failed read (logged out, offline) must never take a page down with it. loading ??= Promise.resolve() .then(() => api.me.prefs()) .then( (r) => { loaded = true; emit(r.prefs[CHAT_UI_KEY] === "1"); }, () => { loading = null; // the next reader asks again } ); } export function useChatUi(): boolean { useEffect(load, []); return useSyncExternalStore(subscribe, () => chatUi); } /** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */ export function setSessionChat(sessionId: string, on: boolean) { const { [sessionId]: _old, ...rest } = views; // Newest last, so the cap drops the sessions touched longest ago. views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS)); try { localStorage.setItem(VIEWS_KEY, JSON.stringify(views)); } catch { // Storage full or blocked: the choice still holds for this page load. } listeners.forEach((fn) => fn()); } /** The session's own view; a session seen for the first time takes the * account's default (once it is read) and keeps it. */ export function useSessionChat(sessionId: string | null | undefined): boolean { const fallback = useChatUi(); const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined)); const ready = useSyncExternalStore(subscribe, () => loaded); useEffect(() => { if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi); }, [sessionId, own, ready]); return own ?? fallback; } export async function saveChatUi(on: boolean): Promise { const before = chatUi; emit(on); try { const r = await api.me.savePrefs({ [CHAT_UI_KEY]: on ? "1" : null }); emit(r.prefs[CHAT_UI_KEY] === "1"); } catch (err) { emit(before); throw err; } } /** Tests: forget what was read. */ export function __resetChatUi() { chatUi = false; loaded = false; loading = null; views = {}; localStorage.removeItem(VIEWS_KEY); }