mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The Coach page gets a conversations panel on the right of the thread. From 1280 px it docks beside the thread and slides open and shut; the reading column keeps its width either way and only its centring moves. It is open on a first visit and remembers the last choice per device. Below 1280 px it is a sheet from the right. The toggle sits at the trailing edge of the top bar, through a small page-owned slot the top bar now renders. The panel lists every conversation grouped by recency, with a row menu to rename in place (Enter saves, Escape cancels without closing the panel) and delete with the existing undo toast. The undo window now survives the sheet closing. New chat is the round button at the panel's bottom right, and picking or starting a conversation keeps ?c= in the URL. The gear in the panel header opens quick settings: a popover from md up, a bottom sheet on a phone. It switches the answering provider (chain reordered through the existing route), the model and reasoning level where the person owns them, and shows what the Coach can see. Keys, the Codex sign-in and the fallback chain stay in Settings > AI. The drawer's gear opens the same overlay. The composer's + menu is gone. Settings > Coach links to /coach?settings=data, which opens the overlay on what the Coach can see instead of a rail that no longer exists.
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useSyncExternalStore } from "react";
|
|
|
|
/**
|
|
* The Coach page's conversations panel: whether it sits docked beside the
|
|
* thread, and whether the person last left it open there.
|
|
*
|
|
* Docked means a viewport of at least 1280 px (`xl`). Below that the panel is
|
|
* a sheet over the page and opens only when asked, so nothing here applies.
|
|
* At `xl` it is open on a first visit and remembers the last choice per
|
|
* device after that.
|
|
*
|
|
* The choice is a convenience, not a record: it lives in `localStorage`, every
|
|
* read and write is wrapped because storage can throw (private windows,
|
|
* blocked site data), and an unreadable store falls back to the first-visit
|
|
* default. Both values come through `useSyncExternalStore`, so the server and
|
|
* the hydrating client agree (docked = false, open = true) and the live value
|
|
* lands on the first client render after hydration.
|
|
*/
|
|
export const COACH_PANEL_STORAGE_KEY = "healthlog.coach.panelOpen";
|
|
|
|
/** The breakpoint at which the panel docks beside the thread. */
|
|
export const COACH_PANEL_DOCKED_QUERY = "(min-width: 1280px)";
|
|
|
|
/** Same-tab change signal; the `storage` event only fires in other tabs. */
|
|
const CHANGE_EVENT = "healthlog:coach-panel-open";
|
|
|
|
/**
|
|
* The last choice made in this tab. Read when the store has nothing, so a
|
|
* click still sticks for the session where storage refuses every write.
|
|
*/
|
|
let sessionChoice: boolean | null = null;
|
|
|
|
/**
|
|
* The remembered choice. `null` = nothing stored yet (a first visit), or a
|
|
* store that cannot be read; the caller treats both as "open".
|
|
*/
|
|
export function readCoachPanelPreference(
|
|
storage: Pick<Storage, "getItem"> | null | undefined,
|
|
): boolean | null {
|
|
if (!storage) return null;
|
|
try {
|
|
const raw = storage.getItem(COACH_PANEL_STORAGE_KEY);
|
|
if (raw === "true") return true;
|
|
if (raw === "false") return false;
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Persist the choice. Returns false when the store refused the write. */
|
|
export function writeCoachPanelPreference(
|
|
storage: Pick<Storage, "setItem"> | null | undefined,
|
|
open: boolean,
|
|
): boolean {
|
|
if (!storage) return false;
|
|
try {
|
|
storage.setItem(COACH_PANEL_STORAGE_KEY, String(open));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** The open state the docked panel paints: a first visit opens it. */
|
|
export function resolveCoachPanelOpen(stored: boolean | null): boolean {
|
|
return stored ?? true;
|
|
}
|
|
|
|
function safeLocalStorage(): Storage | null {
|
|
if (typeof window === "undefined") return null;
|
|
try {
|
|
return window.localStorage;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function subscribePreference(callback: () => void): () => void {
|
|
if (typeof window === "undefined") return () => {};
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key === null || event.key === COACH_PANEL_STORAGE_KEY) {
|
|
callback();
|
|
}
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
window.addEventListener(CHANGE_EVENT, callback);
|
|
return () => {
|
|
window.removeEventListener("storage", onStorage);
|
|
window.removeEventListener(CHANGE_EVENT, callback);
|
|
};
|
|
}
|
|
|
|
function subscribeDocked(callback: () => void): () => void {
|
|
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
|
const mql = window.matchMedia(COACH_PANEL_DOCKED_QUERY);
|
|
mql.addEventListener("change", callback);
|
|
return () => mql.removeEventListener("change", callback);
|
|
}
|
|
|
|
function readDocked(): boolean {
|
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
return window.matchMedia(COACH_PANEL_DOCKED_QUERY).matches;
|
|
}
|
|
|
|
/**
|
|
* `docked`: the viewport is wide enough for the panel to sit beside the
|
|
* thread. `open`: the remembered docked state (meaningful only when docked).
|
|
* `setOpen` persists and notifies every subscriber in this tab.
|
|
*/
|
|
export function useCoachPanelOpen(): {
|
|
docked: boolean;
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
} {
|
|
const docked = useSyncExternalStore(subscribeDocked, readDocked, () => false);
|
|
const open = useSyncExternalStore(
|
|
subscribePreference,
|
|
() =>
|
|
resolveCoachPanelOpen(
|
|
readCoachPanelPreference(safeLocalStorage()) ?? sessionChoice,
|
|
),
|
|
() => true,
|
|
);
|
|
const setOpen = useCallback((next: boolean) => {
|
|
sessionChoice = next;
|
|
writeCoachPanelPreference(safeLocalStorage(), next);
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new Event(CHANGE_EVENT));
|
|
}
|
|
}, []);
|
|
return { docked, open, setOpen };
|
|
}
|