diff --git a/messages/de.json b/messages/de.json
index 4f69db510..617fb1412 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Ausführlich",
"settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
- "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).",
- "settingsSourcesPointerLink": "Coach öffnen",
+ "settingsSourcesPointer": "Welche Daten der Coach lesen darf und wie weit er zurückblickt, stellst du im Coach selbst ein, hinter dem Zahnrad („Worauf ich zugreife“).",
+ "settingsSourcesPointerLink": "Coach-Einstellungen öffnen",
"settingsExcludeLabel": "Metriken ausschließen",
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
"settingsContextLabel": "Kontext mitschicken",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.",
"errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.",
"errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.",
- "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten."
+ "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.",
+ "frame": {
+ "showPanel": "Unterhaltungen einblenden",
+ "hidePanel": "Unterhaltungen ausblenden",
+ "panelDescription": "Deine Unterhaltungen mit dem Coach, neueste zuerst.",
+ "rowActions": "Aktionen für {title}",
+ "rename": "Umbenennen",
+ "delete": "Löschen",
+ "modelSection": "Modell",
+ "provider": "Antwortet mit",
+ "modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.",
+ "noProvider": "Kein Anbieter verfügbar",
+ "saving": "Wird gespeichert…",
+ "saveFailed": "Die Änderung ließ sich nicht speichern.",
+ "allSettings": "Alle KI-Einstellungen"
+ }
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
diff --git a/messages/en.json b/messages/en.json
index 92d60007b..856fdecd4 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Default",
"settingsVerbosityDetailed": "Detailed",
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
- "settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
- "settingsSourcesPointerLink": "Open the coach",
+ "settingsSourcesPointer": "Which data the Coach may read and how far back it looks are set in the Coach itself, behind the gear (\"What I can see\").",
+ "settingsSourcesPointerLink": "Open Coach settings",
"settingsExcludeLabel": "Exclude metrics",
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
"settingsContextLabel": "Include context",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "The Coach is not available inside a record someone shared with you.",
"errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.",
"errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.",
- "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules."
+ "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.",
+ "frame": {
+ "showPanel": "Show conversations",
+ "hidePanel": "Hide conversations",
+ "panelDescription": "Your conversations with the Coach, newest first.",
+ "rowActions": "Actions for {title}",
+ "rename": "Rename",
+ "delete": "Delete",
+ "modelSection": "Model",
+ "provider": "Answers with",
+ "modelManaged": "The model for this provider is set in AI settings.",
+ "noProvider": "No provider available",
+ "saving": "Saving…",
+ "saveFailed": "That change could not be saved.",
+ "allSettings": "All AI settings"
+ }
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
diff --git a/messages/es.json b/messages/es.json
index 7fc4efbfb..2ff2debd6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Estándar",
"settingsVerbosityDetailed": "Detallado",
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
- "settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
- "settingsSourcesPointerLink": "Abrir el coach",
+ "settingsSourcesPointer": "Qué datos puede leer el coach y hasta cuándo mira atrás se ajusta en el propio coach, tras el engranaje («A qué accedo»).",
+ "settingsSourcesPointerLink": "Abrir ajustes del coach",
"settingsExcludeLabel": "Excluir métricas",
"settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.",
"settingsContextLabel": "Enviar contexto",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.",
"errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.",
"errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.",
- "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos."
+ "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.",
+ "frame": {
+ "showPanel": "Mostrar conversaciones",
+ "hidePanel": "Ocultar conversaciones",
+ "panelDescription": "Tus conversaciones con el coach, las más recientes primero.",
+ "rowActions": "Acciones para {title}",
+ "rename": "Renombrar",
+ "delete": "Eliminar",
+ "modelSection": "Modelo",
+ "provider": "Responde con",
+ "modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.",
+ "noProvider": "Ningún proveedor disponible",
+ "saving": "Guardando…",
+ "saveFailed": "No se pudo guardar el cambio.",
+ "allSettings": "Todos los ajustes de IA"
+ }
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
diff --git a/messages/fr.json b/messages/fr.json
index 989e0ab17..78bb57638 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Détaillé",
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
- "settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
- "settingsSourcesPointerLink": "Ouvrir le coach",
+ "settingsSourcesPointer": "Les données que le coach peut lire et jusqu’où il remonte se règlent dans le coach lui-même, derrière la roue dentée (« Ce à quoi j’accède »).",
+ "settingsSourcesPointerLink": "Ouvrir les paramètres du coach",
"settingsExcludeLabel": "Exclure des métriques",
"settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.",
"settingsContextLabel": "Envoyer le contexte",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.",
"errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.",
"errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.",
- "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules."
+ "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.",
+ "frame": {
+ "showPanel": "Afficher les conversations",
+ "hidePanel": "Masquer les conversations",
+ "panelDescription": "Tes conversations avec le coach, les plus récentes d’abord.",
+ "rowActions": "Actions pour {title}",
+ "rename": "Renommer",
+ "delete": "Supprimer",
+ "modelSection": "Modèle",
+ "provider": "Répond avec",
+ "modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.",
+ "noProvider": "Aucun fournisseur disponible",
+ "saving": "Enregistrement…",
+ "saveFailed": "Impossible d’enregistrer cette modification.",
+ "allSettings": "Tous les paramètres IA"
+ }
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
diff --git a/messages/it.json b/messages/it.json
index b8d636332..17bcdf252 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Dettagliata",
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
- "settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
- "settingsSourcesPointerLink": "Apri il coach",
+ "settingsSourcesPointer": "Quali dati il coach può leggere e fin dove guarda indietro si imposta nel coach stesso, dietro l’ingranaggio («A cosa accedo»).",
+ "settingsSourcesPointerLink": "Apri le impostazioni del coach",
"settingsExcludeLabel": "Escludi metriche",
"settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.",
"settingsContextLabel": "Invia contesto",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.",
"errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.",
"errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.",
- "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli."
+ "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.",
+ "frame": {
+ "showPanel": "Mostra conversazioni",
+ "hidePanel": "Nascondi conversazioni",
+ "panelDescription": "Le tue conversazioni con il coach, dalla più recente.",
+ "rowActions": "Azioni per {title}",
+ "rename": "Rinomina",
+ "delete": "Elimina",
+ "modelSection": "Modello",
+ "provider": "Risponde con",
+ "modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.",
+ "noProvider": "Nessun fornitore disponibile",
+ "saving": "Salvataggio…",
+ "saveFailed": "Impossibile salvare la modifica.",
+ "allSettings": "Tutte le impostazioni IA"
+ }
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
diff --git a/messages/ko.json b/messages/ko.json
index 5dc5fadf4..a840c5328 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "기본",
"settingsVerbosityDetailed": "자세히",
"settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요 — 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.",
- "settingsSourcesPointer": "코치가 참고할 데이터 묶음과 기본 분석 기간은 코치 대화의 '내가 참고하는 것' 레일에서 바로 관리해요.",
- "settingsSourcesPointerLink": "코치 열기",
+ "settingsSourcesPointer": "코치가 읽을 수 있는 데이터와 되돌아볼 기간은 코치 안의 톱니바퀴('내가 볼 수 있는 것')에서 정해요.",
+ "settingsSourcesPointerLink": "코치 설정 열기",
"settingsExcludeLabel": "제외할 지표",
"settingsExcludeHint": "선택한 지표의 데이터는 코치가 보지 않아요.",
"settingsContextLabel": "포함할 맥락",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.",
"errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.",
- "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다."
+ "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.",
+ "frame": {
+ "showPanel": "대화 목록 보기",
+ "hidePanel": "대화 목록 숨기기",
+ "panelDescription": "코치와 나눈 대화, 최신순이에요.",
+ "rowActions": "{title} 작업",
+ "rename": "이름 바꾸기",
+ "delete": "삭제",
+ "modelSection": "모델",
+ "provider": "응답 제공자",
+ "modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
+ "noProvider": "사용할 수 있는 제공자가 없어요",
+ "saving": "저장 중…",
+ "saveFailed": "변경 사항을 저장하지 못했어요.",
+ "allSettings": "모든 AI 설정"
+ }
},
"relativeJustNow": "방금",
"relativeMinutesAgoOne": "{count}분 전",
diff --git a/messages/pl.json b/messages/pl.json
index b967ecdc4..12af86e9f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2943,8 +2943,8 @@
"settingsVerbosityDefault": "Standardowa",
"settingsVerbosityDetailed": "Szczegółowa",
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
- "settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
- "settingsSourcesPointerLink": "Otwórz coacha",
+ "settingsSourcesPointer": "To, jakie dane coach może czytać i jak daleko wstecz patrzy, ustawisz w samym coachu, pod kołem zębatym („Do czego mam dostęp”).",
+ "settingsSourcesPointerLink": "Otwórz ustawienia coacha",
"settingsExcludeLabel": "Wyklucz metryki",
"settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.",
"settingsContextLabel": "Wysyłaj kontekst",
@@ -3113,7 +3113,22 @@
"errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.",
"errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.",
"errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.",
- "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły."
+ "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.",
+ "frame": {
+ "showPanel": "Pokaż rozmowy",
+ "hidePanel": "Ukryj rozmowy",
+ "panelDescription": "Twoje rozmowy z coachem, od najnowszej.",
+ "rowActions": "Działania dla {title}",
+ "rename": "Zmień nazwę",
+ "delete": "Usuń",
+ "modelSection": "Model",
+ "provider": "Odpowiada przez",
+ "modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.",
+ "noProvider": "Brak dostępnego dostawcy",
+ "saving": "Zapisywanie…",
+ "saveFailed": "Nie udało się zapisać zmiany.",
+ "allSettings": "Wszystkie ustawienia AI"
+ }
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
diff --git a/src/__tests__/coach-stream-lifecycle-guard.test.ts b/src/__tests__/coach-stream-lifecycle-guard.test.ts
index 466a34f93..7cb5fb0b7 100644
--- a/src/__tests__/coach-stream-lifecycle-guard.test.ts
+++ b/src/__tests__/coach-stream-lifecycle-guard.test.ts
@@ -63,18 +63,26 @@ describe("Coach streaming lifecycle", () => {
"components/insights/coach-panel/coach-conversation.tsx",
);
- // Isolate the HistoryRail onSelect callback body.
+ // One switch handler, shared by the drawer's history tray and the page's
+ // conversations panel, so neither surface can skip the reset.
const match = source.match(
- / {
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 457bf92e0..d42ffa50f 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -24,8 +24,8 @@ import {
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "@/components/insights/coach-panel/use-coach";
-import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
+import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
@@ -58,58 +58,6 @@ import { cn } from "@/lib/utils";
* wrapper matches the Coach page so the two surfaces share one chrome.
*/
-type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier";
-
-interface RecencyGroup {
- id: RecencyGroupId;
- labelKey: string;
- conversations: CoachConversationDTO[];
-}
-
-/**
- * Bucket conversations by LOCAL calendar recency. `updatedAt` is an ISO
- * string; comparisons run against local day boundaries so "Today" tracks
- * the user's own midnight, not UTC.
- */
-function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
- const now = new Date();
- const startOfToday = new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate(),
- ).getTime();
- const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
- // "This week" = the six days before yesterday (a rolling 7-day window
- // that already excludes today + yesterday, which have their own groups).
- const startOfWeek = startOfToday - 6 * 24 * 60 * 60 * 1000;
-
- const groups: Record = {
- today: [],
- yesterday: [],
- thisWeek: [],
- earlier: [],
- };
-
- for (const conversation of conversations) {
- const updated = new Date(conversation.updatedAt).getTime();
- if (updated >= startOfToday) groups.today.push(conversation);
- else if (updated >= startOfYesterday) groups.yesterday.push(conversation);
- else if (updated >= startOfWeek) groups.thisWeek.push(conversation);
- else groups.earlier.push(conversation);
- }
-
- return (
- [
- { id: "today", labelKey: "insights.coach.history.groupToday" },
- { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
- { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
- { id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
- ] as const
- )
- .map((g) => ({ ...g, conversations: groups[g.id] }))
- .filter((g) => g.conversations.length > 0);
-}
-
function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
const { t, locale } = useTranslations();
const [openId, setOpenId] = useState(null);
@@ -139,7 +87,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
);
const isSearching = debouncedFilter.trim().length > 0;
- const groups = useMemo(() => groupByRecency(visible), [visible]);
+ const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
// Same callback-ref pattern as the rail: a `useState` setter (not a plain
// `useRef`) so the sentinel hook's `root` re-fires its effect once the
diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx
index 6ac9fbfc9..f22b463d5 100644
--- a/src/app/coach/page-client.tsx
+++ b/src/app/coach/page-client.tsx
@@ -81,6 +81,13 @@ import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
* this is a prefill, not an auto-send.
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
* only ever applies to a fresh conversation's first turn.
+ *
+ * The conversations panel on the right of the thread carries the history,
+ * New chat and the settings gear (the composer's `+` menu is gone). Picking a
+ * conversation or starting a new one rewrites `?c=` in place, so a reload
+ * stays on the open thread. `?settings=data` (the link from Settings → Coach)
+ * opens the gear on "What I can see"; the param is consumed on mount and
+ * dropped from the URL so a reload does not reopen the overlay.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
@@ -118,6 +125,21 @@ function CoachPageBody() {
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
+ // Read once: the panel consumes it on mount, then the URL loses it.
+ const [openSettingsOnData] = useState(
+ () => searchParams.get("settings") === "data",
+ );
+ useEffect(() => {
+ if (!openSettingsOnData) return;
+ const url = new URL(window.location.href);
+ url.searchParams.delete("settings");
+ window.history.replaceState(
+ window.history.state,
+ "",
+ url.pathname + url.search,
+ );
+ }, [openSettingsOnData]);
+
// #781 — a navigation away mid-reply recorded the interrupted conversation
// (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the
// Coach reopens that thread — where the persisted `cancelled` marker shows
@@ -200,6 +222,7 @@ function CoachPageBody() {
// unread coach-initiated exception and the interrupted first-turn
// resume (#781, whose conversation id never reached the client).
autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest}
+ openSettingsOnData={openSettingsOnData}
/>
);
}
diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx
index 3cd40e333..21626c678 100644
--- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx
@@ -158,16 +158,16 @@ describe(" page deep-link (#67)", () => {
expect(html).toContain('data-slot="coach-hero"');
});
- // v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the
- // composer's `+` actions menu. The page chrome is the composer alone, so the
- // surface exposes the `+` actions trigger and no standalone toolbar gear.
- it("drops the page-toolbar gear and exposes the composer + actions menu", () => {
+ // The page carries no toolbar and the composer no `+` menu: New chat, the
+ // conversation list and the settings gear live in the conversations panel.
+ // On the server the panel is closed (it docks only once the client knows
+ // the viewport), so the SSR markup holds the conversation column alone.
+ it("renders the conversation column with no toolbar and no composer menu", () => {
const html = render(, makeClient());
- // The old page toolbar + its gear are gone.
+ expect(html).toContain('data-slot="coach-page-main"');
expect(html).not.toContain('data-slot="coach-page-settings"');
expect(html).not.toContain('data-slot="coach-page-toolbar"');
- // Settings now lives behind the composer's `+` actions menu (the menu
- // content is portalled open-on-demand, so SSR shows only the trigger).
- expect(html).toContain('data-slot="coach-input-actions"');
+ expect(html).not.toContain('data-slot="coach-input-actions"');
+ expect(html).not.toContain('href="/coach/conversations"');
});
});
diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
index 99e93bcea..d6f200bf4 100644
--- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx
@@ -229,10 +229,9 @@ describe("", () => {
expect(handler).toHaveBeenCalledWith("typed");
});
- it("omits the control-hub action row by default (drawer composer)", () => {
- // v1.18.11 (W11) — the hub is page-only; without `showHub` the composer
- // stays the single-row drawer layout (mic + send on one baseline) and
- // grows no actions menu or settings deep-link.
+ it("renders the single-row composer with no actions menu", () => {
+ // The composer carries text, attach and send only; it grows no actions
+ // menu or settings deep-link on either surface.
const html = render(
{}} onSubmit={() => {}} />,
);
@@ -251,26 +250,25 @@ describe("", () => {
value=""
onChange={() => {}}
onSubmit={() => {}}
- showHub
- onNewChat={() => {}}
- onOpenHistory={() => {}}
+ attachEnabled
+ onPickFromVault={() => {}}
+ onUploadNew={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-input-controls"');
expect(html).toContain('data-slot="coach-input-leading"');
- expect(html).toContain('data-slot="coach-input-actions"');
- expect(html).not.toContain('data-slot="coach-input-settings"');
+ expect(html).toContain('data-slot="coach-input-attach"');
expect(html).not.toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
// DOM and visual order agree at every breakpoint: the textarea comes first,
- // followed by the leading actions and then Send.
+ // followed by the leading attach control and then Send.
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
- const plusIdx = html.indexOf('data-slot="coach-input-actions"');
+ const attachIdx = html.indexOf('data-slot="coach-input-attach"');
const sendIdx = html.indexOf('data-slot="coach-input-send"');
expect(textareaIdx).toBeGreaterThan(-1);
- expect(textareaIdx).toBeLessThan(plusIdx);
- expect(plusIdx).toBeLessThan(sendIdx);
+ expect(textareaIdx).toBeLessThan(attachIdx);
+ expect(attachIdx).toBeLessThan(sendIdx);
const controls = html.match(
/
]*data-slot="coach-input-controls"[^>]*>/,
@@ -295,22 +293,28 @@ describe("", () => {
expect(controls?.[0]).toMatch(/\bself-end\b/);
});
- it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
+ it("carries no conversation menu: New chat, Conversations and Settings live in the page's panel", () => {
const html = render(
{}}
onSubmit={() => {}}
- showHub
- onNewChat={() => {}}
- onOpenHistory={() => {}}
+ attachEnabled
+ onPickFromVault={() => {}}
+ onUploadNew={() => {}}
/>,
);
- const actions = html.match(
- /
) : null;
- // v1.18.11 (W11) — the PAGE surface drops the top header bar and the
- // rail-tray strip entirely. The composer is the single control hub; the
- // thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer
- // (`mx-auto max-w-2xl`) share ONE centred, max-width-capped column so the
- // composer never changes width between the new-chat hero and an active
- // conversation. The drawer surface keeps its own header + body chrome
- // (handled below) untouched.
+ // The PAGE surface has no header bar of its own. Conversations, New chat
+ // and the settings gear live in the conversations panel on the right; its
+ // toggle sits in the app's top bar. The drawer surface keeps its own header
+ // + body chrome (handled below).
if (surface === "page") {
return (
- {docScopeBanner}
- {!heroActive ?
{title}
: null}
- {/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives
- in the composer's `+` actions menu alongside New chat and
- Conversations, keeping the page chrome to the composer alone. */}
- {heroActive ? (
-
- ) : (
- <>
-
-
-
- {/* Docked composer — the SAME centred, capped column as the
- thread (and the hero composer), so the width is constant
- across the new-chat → conversation transition. */}
-
-
{composerStack}
-
- >
- )}
+ {/* The conversation column. Its thread and composer keep one centred,
+ capped column (`max-w-2xl`) across the new-chat hero and an active
+ conversation, and that width does not change when the panel
+ beside it opens or closes: only the centring moves. */}
+
+ {docScopeBanner}
+ {!heroActive ?
{title}
: null}
+ {heroActive ? (
+
+ ) : (
+ <>
+
+
+
+
+
{composerStack}
+
+ >
+ )}
+
+
{attachEnabled ? (
- {/* v1.16.1 — the Coach preferences moved into Settings → AI
- (one place for model + behaviour). The header keeps a gear
- that deep-links there instead of opening an in-chat sheet. */}
-
-
-
-
-
+ {/* The same quick settings the page's panel opens: model and what
+ the Coach may read. Everything else stays in Settings → AI. */}
+
{trailingHeaderActions}
@@ -1299,22 +1336,7 @@ export function CoachConversation({
historyRail={
{
- setCurrentConversationId(id);
- setHistoryTrayOpen(false);
- setPendingAdopt(null);
- // v1.29.x (S7) — the selected thread's own `fenced` flag +
- // `attachments` are now authoritative; drop any staged `?doc=`
- // seed so a pending attachment can't leak across a thread switch.
- setPendingAttachmentIds([]);
- dispatchGuided({ type: "RESET" });
- // Drop the finished stream's trailing state, as `handleNewChat`
- // already does. Without it the previous thread's last assistant
- // or error bubble renders appended to the newly selected thread
- // — `streamingActive` stays true because that messageId is
- // absent from the new thread's messages — until the next send.
- send.reset();
- }}
+ onSelect={selectConversation}
/>
}
sourcesOpen={sourcesTrayOpen}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx
index 6d7680a32..6784290d6 100644
--- a/src/components/insights/coach-panel/coach-input.tsx
+++ b/src/components/insights/coach-panel/coach-input.tsx
@@ -7,17 +7,12 @@ import {
useEffect,
useRef,
} from "react";
-import Link from "next/link";
import {
FolderOpen,
Loader2,
- MessagesSquare,
Paperclip,
- Plus,
Send,
- Settings,
Square,
- Target,
Upload,
} from "lucide-react";
@@ -55,10 +50,8 @@ import { COACH_COMPOSER_ID } from "./composer-focus";
* the guard stack (system prompt, prose-grounding check, outbound
* refusal), not a rendered disclaimer line.
*
- * v1.18.11 (W11): the composer is the conversation's control hub on the
- * full-page Coach surface. When `showHub` is set it includes a `+` actions menu
- * for a new chat or conversation history. The drawer omits `showHub` because
- * its header already carries those actions.
+ * New chat, the conversation list and the settings gear live in the Coach
+ * page's conversations panel (and the drawer's header), not in the composer.
*
* Voice input was removed after repeated browser and permission failures made
* the control unreliable. The composer now exposes only actions that work
@@ -95,28 +88,12 @@ export interface CoachInputProps {
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
- /**
- * v1.18.11 — mount the control-hub action row (leading `+` menu +
- * settings link) inside the composer card. The page surface sets this;
- * the drawer leaves it off and keeps the single-row composer.
- */
- showHub?: boolean;
- /**
- * v1.18.11 — start a fresh conversation. Wired into the `+` actions
- * menu. Required when `showHub` is set.
- */
- onNewChat?: () => void;
- /**
- * v1.18.11 — open the left conversation-history drawer. Wired into the
- * `+` actions menu. Required when `showHub` is set.
- */
- onOpenHistory?: () => void;
/**
* v1.29.x (S7) — mount the document-attach trigger (a leading paperclip menu:
* "Choose from documents" + "Upload new document"). Set by the parent only
* when the `inboundDocuments` module is enabled for the user; hidden entirely
- * otherwise. Separate from the `showHub` `+` actions menu — this affordance
- * stages fenced document attachments, it does not manage conversations.
+ * otherwise. It stages fenced document attachments; conversations are managed
+ * in the page's conversations panel.
*/
attachEnabled?: boolean;
/**
@@ -177,9 +154,6 @@ export function CoachInput({
inputId = COACH_COMPOSER_ID,
autoFocusOnOpen = false,
placeholder,
- showHub = false,
- onNewChat,
- onOpenHistory,
attachEnabled = false,
onPickFromVault,
onUploadNew,
@@ -293,61 +267,10 @@ export function CoachInput({
);
- // Leading `+` actions menu for the full-page Coach surface. The settings
- // shortcut remains in this menu while the dedicated gear stays in the page
- // toolbar.
- const actionsButton = (
-
-
-
-
-
-
-
- onNewChat?.()}
- >
-
- {t("insights.coach.newChat")}
-
- onOpenHistory?.()}
- >
-
- {t("insights.coach.historyTitle")}
-
-
-
-
- {t("coach.plans.title")}
-
-
-
-
-
- {t("insights.coach.settings")}
-
-
-
-
- );
-
// v1.29.x (S7) — the document-attach affordance: a leading paperclip menu
// ("Choose from documents" opens the vault picker; "Upload new document"
// opens the file input, reusing the existing `/api/documents/inbound`
- // pipeline). Rendered only when the parent enables it (module-gated). It is a
- // SEPARATE control from the `showHub` `+` actions menu — the two never merge.
+ // pipeline). Rendered only when the parent enables it (module-gated).
const attachButton = attachEnabled ? (
<>
{attachButton}
- {showHub ? actionsButton : null}
-
-
- {sendButton}
+
{sendButton}
diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx
new file mode 100644
index 000000000..079d99c96
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-model-picker.tsx
@@ -0,0 +1,351 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2 } from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
+import {
+ ANTHROPIC_MODEL_PRESETS,
+ LOCAL_MODEL_PRESETS,
+ OPENAI_MODEL_PRESETS,
+ uiToLegacyProviderEnum,
+ type ProviderChainData,
+ type ProviderType,
+ type ReasoningEffort,
+ type UserAIProvider,
+} from "@/components/settings/ai/shared";
+import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ aiInputDependentKeys,
+ invalidateKeys,
+ queryKeys,
+} from "@/lib/query-keys";
+
+/**
+ * The quick model choice in the Coach's settings overlay: which provider
+ * answers, which model it uses, and how hard a reasoning model thinks.
+ *
+ * It is a shortcut onto the same settings Settings → AI writes, never a second
+ * copy of them. Keys, base URLs, the Codex sign-in and the order of the
+ * fallback chain stay there; this reads the same two queries and writes
+ * through the same two routes:
+ * - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
+ * to the front and every other entry in its current order. The entries'
+ * reasoning settings are not sent, so the server keeps what it stored.
+ * - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
+ * owns the setting (their own key's provider, or their gateway). The
+ * operator's shared provider and Codex show their state read-only.
+ * Every write invalidates the AI input keys, so the capability on
+ * `/api/auth/me` comes back from the server rather than being guessed here.
+ */
+
+/** The configured chain with `provider` first; everything else keeps order. */
+export function chainWithFirst(
+ chain: ProviderChainData["configuredChain"],
+ provider: ProviderType,
+): { providerType: ProviderType; priority: number; enabled: boolean }[] {
+ const rest = chain.filter((entry) => entry.providerType !== provider);
+ const ordered = [
+ { providerType: provider, enabled: true },
+ ...rest.map((entry) => ({
+ providerType: entry.providerType,
+ enabled: entry.enabled,
+ })),
+ ];
+ return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 }));
+}
+
+/** The providers a person can switch to: enabled and able to answer. */
+export function selectableProviders(
+ chain: ProviderChainData["configuredChain"],
+): ProviderType[] {
+ return chain
+ .filter((entry) => entry.enabled && entry.available)
+ .map((entry) => entry.providerType);
+}
+
+export type ModelControl =
+ | { kind: "preset"; presets: readonly string[]; value: string | null }
+ | { kind: "gateway"; value: string | null }
+ | { kind: "managed" };
+
+const PRESETS: Partial> = {
+ openai: OPENAI_MODEL_PRESETS,
+ anthropic: ANTHROPIC_MODEL_PRESETS,
+ local: LOCAL_MODEL_PRESETS,
+};
+
+/**
+ * Which model control the active provider gets. The model column is shared by
+ * the OpenAI, Anthropic and Local providers and belongs to whichever of them
+ * the person configured, so the control is offered only for that one; a
+ * model name chosen for another would be sent to the wrong vendor.
+ */
+export function modelControlFor(
+ active: ProviderType | null,
+ userProvider: UserAIProvider | null | undefined,
+): ModelControl {
+ if (!active || !userProvider) return { kind: "managed" };
+ if (active === "openai-compatible") {
+ return { kind: "gateway", value: userProvider.compatModel };
+ }
+ const presets = PRESETS[active];
+ if (presets && userProvider.provider === uiToLegacyProviderEnum(active)) {
+ return { kind: "preset", presets, value: userProvider.model };
+ }
+ return { kind: "managed" };
+}
+
+/** The reasoning setting the active provider carries, if any. */
+export function reasoningFieldFor(
+ active: ProviderType | null,
+ userProvider: UserAIProvider | null | undefined,
+): {
+ field: "localReasoningEffort" | "compatReasoningEffort";
+ value: ReasoningEffort | null;
+} | null {
+ if (active === "local") {
+ return {
+ field: "localReasoningEffort",
+ value: userProvider?.localReasoningEffort ?? null,
+ };
+ }
+ if (active === "openai-compatible") {
+ return {
+ field: "compatReasoningEffort",
+ value: userProvider?.compatReasoningEffort ?? null,
+ };
+ }
+ return null;
+}
+
+export function CoachModelPicker() {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+
+ const chainQuery = useQuery({
+ queryKey: queryKeys.insightsProviderChain(),
+ queryFn: () => apiGet("/api/insights/provider-chain"),
+ });
+ const providerQuery = useQuery({
+ queryKey: queryKeys.userAiProvider(),
+ queryFn: () => apiGet("/api/user/ai-provider"),
+ });
+
+ const [error, setError] = useState(null);
+
+ const onSaved = () => {
+ setError(null);
+ void invalidateKeys(queryClient, aiInputDependentKeys);
+ };
+ const onFailed = (e: unknown) =>
+ setError(
+ e instanceof Error && e.message
+ ? e.message
+ : t("insights.coach.frame.saveFailed"),
+ );
+
+ const switchProvider = useMutation({
+ mutationFn: (provider: ProviderType) =>
+ apiPut("/api/insights/provider-chain", {
+ chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider),
+ }),
+ onSuccess: onSaved,
+ onError: onFailed,
+ });
+ const patchProvider = useMutation({
+ mutationFn: (body: Record) =>
+ apiPatch("/api/user/ai-provider", body),
+ onSuccess: onSaved,
+ onError: onFailed,
+ });
+
+ const chain = chainQuery.data;
+ const userProvider = providerQuery.data;
+ const active = chain?.activeProvider ?? null;
+ const options = chain ? selectableProviders(chain.configuredChain) : [];
+ const model = modelControlFor(active, userProvider);
+ const reasoning = reasoningFieldFor(active, userProvider);
+ const busy = switchProvider.isPending || patchProvider.isPending;
+
+ if (chainQuery.isError || providerQuery.isError) {
+ return (
+
+ );
+}
+
+/** A gateway names its own models, so its model is free text with a save. */
+function GatewayModelField({
+ value,
+ disabled,
+ onSave,
+}: {
+ value: string | null;
+ disabled: boolean;
+ onSave: (next: string | null) => void;
+}) {
+ const { t } = useTranslations();
+ const [draft, setDraft] = useState(value ?? "");
+ const changed = draft.trim() !== (value ?? "");
+ return (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-settings-body.tsx b/src/components/insights/coach-panel/coach-settings-body.tsx
new file mode 100644
index 000000000..2a844cbb9
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-settings-body.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import Link from "next/link";
+import { Cpu } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+
+import { CoachModelPicker } from "./coach-model-picker";
+import { SourcesRail } from "./sources-rail";
+
+/**
+ * The content of the Coach's quick settings (see `coach-settings-overlay`):
+ * the model choice, "What I can see" (the existing sources rail, wired to the
+ * saved Coach preferences: data areas and how far back the Coach looks) and
+ * a way on to the full AI settings.
+ *
+ * `focusData` lands focus on the "What I can see" section when the body
+ * mounts, for the `/coach?settings=data` deep link. It runs here rather than
+ * in the overlay because the body loads on demand and may arrive after the
+ * popover or sheet has already placed focus.
+ */
+export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
+
+export function CoachSettingsBody({
+ focusData = false,
+}: {
+ focusData?: boolean;
+}) {
+ const { t } = useTranslations();
+ const dataRef = useRef(null);
+ useEffect(() => {
+ if (!focusData) return;
+ const frame = requestAnimationFrame(() => {
+ dataRef.current?.focus();
+ dataRef.current?.scrollIntoView({ block: "start" });
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [focusData]);
+ return (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-settings-overlay.tsx b/src/components/insights/coach-panel/coach-settings-overlay.tsx
new file mode 100644
index 000000000..1a0d0a12a
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-settings-overlay.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useRef } from "react";
+import dynamic from "next/dynamic";
+import { Loader2, Settings } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { useIsMobile } from "@/hooks/use-is-mobile";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+// The body (model picker, the sources rail, their queries) loads when the
+// gear is first opened, as one shared chunk, instead of riding along with
+// every route that can mount the Coach drawer.
+const CoachSettingsBody = dynamic(
+ () => import("./coach-settings-body").then((m) => m.CoachSettingsBody),
+ { ssr: false, loading: () => },
+);
+
+function BodyLoading() {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The Coach's quick settings, behind the gear: which model answers, and what
+ * the Coach may read.
+ *
+ * A popover anchored on the gear from `md` up, a bottom sheet on a phone.
+ * Both trap focus, close on Escape and hand focus back to the gear. Nothing
+ * in here does model work; the provider write invalidates the AI input keys
+ * and the capability comes back from the server.
+ *
+ * `focusData` opens it on the "What I can see" section: Settings → Coach
+ * links here with `/coach?settings=data`.
+ */
+export interface CoachSettingsOverlayProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Open on the "What I can see" section instead of the top. */
+ focusData?: boolean;
+ /** Classes for the gear button, so each header sizes it like its row. */
+ className?: string;
+}
+
+export function CoachSettingsOverlay({
+ open,
+ onOpenChange,
+ focusData = false,
+ className,
+}: CoachSettingsOverlayProps) {
+ const { t } = useTranslations();
+ const isPhone = useIsMobile("md");
+ const gearRef = useRef(null);
+
+ const gear = (
+ onOpenChange(true) : undefined}
+ className={cn(
+ "text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9",
+ className,
+ )}
+ >
+
+
+ );
+
+ if (isPhone) {
+ return (
+ <>
+ {gear}
+ {
+ event.preventDefault();
+ gearRef.current?.focus();
+ }}
+ >
+
+
+ >
+ );
+ }
+
+ return (
+
+ {gear}
+ {
+ // The body moves focus to its data section once it has loaded.
+ if (focusData) event.preventDefault();
+ }}
+ >
+
+ {t("insights.coach.settingsAriaLabel")}
+
+
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/conversation-rename.tsx b/src/components/insights/coach-panel/conversation-rename.tsx
index a45e123c6..a55664613 100644
--- a/src/components/insights/coach-panel/conversation-rename.tsx
+++ b/src/components/insights/coach-panel/conversation-rename.tsx
@@ -25,18 +25,39 @@ export function getConversationRenameKeyAction(
export interface ConversationRenameProps {
id: string;
title: string;
- compact?: boolean;
+ /**
+ * Controlled mode, for a surface that opens the editor from somewhere else
+ * (the conversations panel's row menu). When set, the component renders no
+ * trigger of its own: it shows the form while `editing` is true and reports
+ * the end of an edit (saved or cancelled) through `onEditingChange`.
+ */
+ editing?: boolean;
+ onEditingChange?: (editing: boolean) => void;
}
export function ConversationRename({
id,
title,
- compact = false,
+ editing: controlledEditing,
+ onEditingChange,
}: ConversationRenameProps) {
const { t } = useTranslations();
const rename = useRenameCoachConversation();
- const [editing, setEditing] = useState(false);
+ const controlled = controlledEditing !== undefined;
+ const [localEditing, setLocalEditing] = useState(false);
+ const editing = controlled ? controlledEditing : localEditing;
const [draft, setDraft] = useState(title);
+ // A controlled editor opens from outside, so it seeds its draft from the
+ // current title each time it opens (render-phase, no effect).
+ const [seededOpen, setSeededOpen] = useState(false);
+ if (controlled && editing !== seededOpen) {
+ setSeededOpen(editing);
+ if (editing) setDraft(title);
+ }
+ function setEditing(next: boolean) {
+ if (controlled) onEditingChange?.(next);
+ else setLocalEditing(next);
+ }
const submittingRef = useRef(false);
function cancel() {
@@ -82,23 +103,27 @@ export function ConversationRename({
const action = getConversationRenameKeyAction(event.key);
if (!action) return;
event.preventDefault();
+ // Escape belongs to the editor while it is open: the panel and the sheet
+ // around it must not close on the same key press.
+ event.stopPropagation();
if (action === "cancel") cancel();
else void save();
}
if (!editing) {
+ if (controlled) return null;
return (
{
setDraft(title);
setEditing(true);
}}
aria-label={t("insights.coach.rename.action")}
data-slot="coach-conversation-rename"
- className={cn("shrink-0", compact && "size-11")}
+ className="shrink-0"
>
@@ -115,7 +140,7 @@ export function ConversationRename({
data-slot="coach-conversation-rename-form"
className={cn(
"bg-background absolute inset-y-1 left-1 z-10 flex min-w-0 items-center gap-1",
- compact ? "right-14" : "right-16",
+ controlled ? "right-1" : "right-16",
)}
>
void;
+ onNewChat: () => void;
+ /**
+ * `/coach?settings=data`: reveal the panel and open the settings gear on
+ * the "What I can see" section. Read once, on mount.
+ */
+ openSettingsOnData?: boolean;
+}
+
+export function ConversationsPanel({
+ activeId,
+ onSelect,
+ onNewChat,
+ openSettingsOnData = false,
+}: ConversationsPanelProps) {
+ const { t } = useTranslations();
+ const { docked, open: rememberedOpen, setOpen } = useCoachPanelOpen();
+ const toggleRef = useRef(null);
+ // Owned here, not by the list, so a pending delete keeps its undo window
+ // when the sheet (and the list inside it) closes.
+ const deletion = useDeleteCoachConversationWithUndo();
+
+ // A settings deep-link shows the panel without rewriting the remembered
+ // choice; the next toggle settles it.
+ const [revealed, setRevealed] = useState(openSettingsOnData);
+ const [sheetOpen, setSheetOpen] = useState(openSettingsOnData);
+ const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
+ const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
+
+ const dockedOpen = rememberedOpen || revealed;
+ const expanded = docked ? dockedOpen : sheetOpen;
+
+ function toggle() {
+ if (docked) {
+ setRevealed(false);
+ setOpen(!dockedOpen);
+ } else {
+ setSheetOpen(!sheetOpen);
+ }
+ }
+
+ function closeDocked() {
+ setRevealed(false);
+ setOpen(false);
+ toggleRef.current?.focus();
+ }
+
+ function afterPick() {
+ if (!docked) setSheetOpen(false);
+ }
+
+ const toggleLabel = expanded
+ ? t("insights.coach.frame.hidePanel")
+ : t("insights.coach.frame.showPanel");
+
+ const header = (inSheet: boolean) => (
+
+ );
+
+ const body = (inSheet: boolean) => (
+ <>
+ {header(inSheet)}
+ {
+ onSelect(id);
+ afterPick();
+ }}
+ deletion={deletion}
+ onDeleteActive={() => onNewChat()}
+ onUndoDeleteActive={(id) => onSelect(id)}
+ className="min-h-0 flex-1"
+ />
+ {
+ onNewChat();
+ afterPick();
+ }}
+ data-slot="coach-panel-new-chat"
+ aria-label={t("insights.coach.newChat")}
+ title={t("insights.coach.newChat")}
+ className="absolute right-4 bottom-4 size-12 rounded-full shadow-md"
+ >
+
+
+ >
+ );
+
+ return (
+ <>
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {docked ? (
+
+ ) : (
+
+ {
+ // Land on the sheet itself, not its first control: on a phone
+ // focusing the search field would raise the keyboard over the
+ // list the person opened the sheet to read.
+ event.preventDefault();
+ (event.currentTarget as HTMLElement | null)?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ // The toggle lives in the top bar, outside the sheet; hand
+ // focus back to it explicitly.
+ event.preventDefault();
+ toggleRef.current?.focus();
+ }}
+ onEscapeKeyDown={(event) => {
+ // The rename field takes Escape for itself (cancel the edit);
+ // the sheet stays open. Radix listens in the capture phase, so
+ // the field's own handler has not run yet when this fires.
+ const target = event.target as Element | null;
+ if (
+ target?.closest?.(
+ '[data-slot="coach-conversation-rename-form"]',
+ )
+ ) {
+ event.preventDefault();
+ }
+ }}
+ >
+
+ {t("insights.coach.frame.panelDescription")}
+
+ {body(true)}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index d8cd1675a..d7756af6d 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -1,10 +1,12 @@
"use client";
-import { useMemo, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import {
Loader2,
MessagesSquare,
+ MoreHorizontal,
Paperclip,
+ Pencil,
RotateCw,
Search,
Trash2,
@@ -12,12 +14,19 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
-import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
+import type { CoachConversationDTO } from "@/lib/ai/coach/types";
+import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
import { COACH_SCROLLBAR } from "./message-thread";
import { ConversationRename } from "./conversation-rename";
@@ -27,36 +36,46 @@ import {
} from "./use-coach";
/**
- * v1.4.20 phase B2b — conversation history rail.
+ * The Coach's conversation list: search, the full history grouped by recency
+ * (Today / Yesterday / This week / Earlier), and one row per conversation with
+ * a "⋯" menu for Rename and Delete.
*
- * Lives in the left column of the Coach drawer on `lg+`. Renders the
- * caller's FULL conversation history (v1.30.2 — cursor-paginated via
- * `useInfiniteQuery`, loaded incrementally as the list scrolls near its
- * end), lets the user search by title (server-side, debounced 200 ms),
- * select one to resume, and delete with an undo-able confirm.
+ * It fills the Coach page's conversations panel and the drawer's history
+ * tray. The history is cursor-paginated (`useInfiniteQuery`, loaded as the
+ * list scrolls near its end) and searched server-side on the title, debounced
+ * 200 ms.
*
- * Selection / activeId is a controlled prop so the drawer owns the
- * authoritative state across the message thread + composer.
+ * Rename edits the title in place (`ConversationRename`, Enter saves, Escape
+ * cancels without closing the panel around it) and returns focus to the row's
+ * menu button. Delete hides the row at once and commits after the undo window
+ * (`useDeleteCoachConversationWithUndo`); the toast carries the Undo. A
+ * surface that can unmount the list while a delete is pending (the panel's
+ * sheet) passes its own `deletion` from a component that stays mounted, so
+ * closing the sheet does not cut the undo window short.
*
- * Empty + loading + error states are all rendered inline — the rail
- * never collapses to "0 px" so the layout stays predictable.
+ * Selection is a controlled prop: the surface owns the active conversation.
+ * Loading, error and empty states render inline so the list never collapses.
*/
export interface HistoryRailProps {
activeId: string | null;
onSelect: (id: string) => void;
- /**
- * Optional className passthrough so the parent can override the
- * default layout (currently the drawer wraps it in an `
) : isError && visible.length === 0 ? (
- // A load failure must not masquerade as "no conversations yet" —
- // offer a retry instead of the empty copy.
+ // A load failure must not read as "no conversations yet".
- onSelect(c.id)}
- className="min-w-0 flex-1 truncate text-left"
- data-slot="coach-history-select"
- >
-
- {/* v1.29.x (S7) — a fenced thread runs on the hardened
- fenced endpoint over its attached documents; badge it
- with a paperclip (+ count when more than one) so it
- reads as distinct in the rail. */}
- {c.fenced ? (
-
-
- {(c.attachments?.length ?? 0) > 1 ? (
-
- {c.attachments?.length}
-
- ) : null}
-
- ) : null}
- {c.title}
-
-
- {formatRelativeTime(c.updatedAt, t, locale)}
-
-
-
- {/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
- reveal: touch surfaces have no hover, so the delete
- affordance was invisible on mobile. The button
- sits at 44 px always-visible per the WCAG 2.5.5
- floor. v1.30.1 M5 — one tap deletes (with an Undo
- toast); the old arm/confirm double-tap is gone. */}
- handleDeleteRequest(c.id)}
- aria-label={t("insights.coach.historyDeleteAria")}
- data-slot="coach-history-delete"
- className="size-11 shrink-0"
- >
-
-
-
- );
- })
+ groups.map((group) => (
+
+ {/* h3: one step under the panel's h2 title, and a sibling of the
+ list's own h3 label where that shows (heading order). */}
+
+ {t(group.labelKey)}
+
+
+ {group.conversations.map((c) => (
+
+ ))}
+
+
+ ))
)}
- {/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this
- div into the rail's own scrollport pulls the next cursor page.
- Empty + aria-hidden — the loading row right below it is the
- a11y-visible signal. */}
+ {/* IntersectionObserver sentinel: scrolling it into the list's own
+ scrollport pulls the next cursor page. */}
{visible.length > 0 && hasNextPage ? (
) : null}
@@ -292,3 +256,133 @@ export function HistoryRail({
);
}
+
+function HistoryRow({
+ conversation: c,
+ active,
+ onSelect,
+ onDelete,
+}: {
+ conversation: CoachConversationDTO;
+ active: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+}) {
+ const { t } = useTranslations();
+ const [renaming, setRenaming] = useState(false);
+ const menuTriggerRef = useRef(null);
+ // Set when the menu closes INTO the rename editor, so the menu does not
+ // hand focus back to its trigger and steal it from the title field.
+ const openingRenameRef = useRef(false);
+
+ function endRename() {
+ setRenaming(false);
+ // Back to the row's menu button, where the edit started.
+ requestAnimationFrame(() => menuTriggerRef.current?.focus());
+ }
+
+ return (
+
+ onSelect(c.id)}
+ aria-current={active ? "true" : undefined}
+ title={c.title}
+ data-slot="coach-history-select"
+ className={cn(
+ "flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-lg py-2 pr-11 pl-2 text-left text-sm transition-colors",
+ "focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
+ // The open conversation reads like the open page in the left
+ // navigation: the same primary wash, so the two rails agree.
+ active
+ ? "bg-primary/10 text-primary font-medium"
+ : "text-foreground hover:bg-muted/60",
+ )}
+ >
+ {/* A fenced thread runs on the hardened document endpoint over its
+ attachments; the paperclip (with a count above one) marks it. */}
+ {c.fenced ? (
+
+
+ {(c.attachments?.length ?? 0) > 1 ? (
+
+ {c.attachments?.length}
+
+ ) : null}
+
+ ) : null}
+ {c.title}
+
+
+ {renaming ? null : (
+
+
+
+
+
+
+ {
+ if (openingRenameRef.current) {
+ openingRenameRef.current = false;
+ event.preventDefault();
+ }
+ }}
+ >
+ {
+ openingRenameRef.current = true;
+ setRenaming(true);
+ }}
+ >
+
+ {t("insights.coach.frame.rename")}
+
+ onDelete(c.id)}
+ >
+
+ {t("insights.coach.frame.delete")}
+
+
+
+ )}
+
+ {
+ if (!next) endRename();
+ }}
+ />
+
+ );
+}
diff --git a/src/components/layout/__tests__/top-bar-actions.test.tsx b/src/components/layout/__tests__/top-bar-actions.test.tsx
new file mode 100644
index 000000000..ce9b865b6
--- /dev/null
+++ b/src/components/layout/__tests__/top-bar-actions.test.tsx
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { TopBarActions, TopBarActionsOutlet } from "../top-bar-actions";
+
+describe("top bar actions slot", () => {
+ it("renders an empty outlet that takes itself out of the row", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toBe(
+ '',
+ );
+ });
+
+ it("renders nothing in place: page actions only ever appear in the outlet", () => {
+ // On the server (and before the top bar has mounted) there is no outlet,
+ // so the page's actions render nowhere rather than inline in the page.
+ const html = renderToStaticMarkup(
+
+
+ Toggle
+
+
,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/layout/top-bar-actions.tsx b/src/components/layout/top-bar-actions.tsx
new file mode 100644
index 000000000..c053846e3
--- /dev/null
+++ b/src/components/layout/top-bar-actions.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { createPortal } from "react-dom";
+
+/**
+ * A page-owned slot at the trailing edge of the top bar.
+ *
+ * The top bar is shell chrome and knows nothing about pages; a page that
+ * needs a control up there (the Coach's conversations-panel toggle) renders
+ * `` anywhere in its own tree and the content is portalled
+ * into ``. Portalling keeps the control inside the
+ * page's React tree, so it reads the page's state and context directly and
+ * no node has to be handed across a context on every render.
+ *
+ * The slot empties itself when the page unmounts: the portal goes with the
+ * component that rendered it, so no other route ever shows a stale control.
+ *
+ * The outlet's element is held in a module-level store rather than a context
+ * provider. The shell mounts exactly one top bar, so there is one outlet, and
+ * a store keeps the shell's tree untouched.
+ */
+let outlet: HTMLElement | null = null;
+const listeners = new Set<() => void>();
+
+function setOutlet(node: HTMLElement | null) {
+ if (outlet === node) return;
+ outlet = node;
+ for (const listener of listeners) listener();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+/** Where the top bar paints page actions. Empty unless a page fills it. */
+export function TopBarActionsOutlet({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+/** Render `children` into the top bar's action slot. */
+export function TopBarActions({ children }: { children: React.ReactNode }) {
+ const target = useSyncExternalStore(
+ subscribe,
+ () => outlet,
+ () => null,
+ );
+ if (!target) return null;
+ return createPortal(children, target);
+}
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx
index f2aa2aaf8..71436d8b5 100644
--- a/src/components/layout/top-bar.tsx
+++ b/src/components/layout/top-bar.tsx
@@ -15,6 +15,7 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
+import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -106,6 +107,12 @@ export function TopBar() {
{/* Desktop: empty spacer (user controls are in sidebar) */}
+ {/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
+ keeps them at the trailing edge, right before the mobile avatar
+ menu; `empty:hidden` takes the slot out of the row on every page
+ that leaves it empty, so the header lays out exactly as before. */}
+
+
{/* Mobile-only auth section (desktop uses sidebar user section) */}
{isLoading ? (
diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
index 8eddb9860..4a2ecb8da 100644
--- a/src/components/settings/__tests__/coach-links-without-coach.test.tsx
+++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
@@ -54,16 +54,16 @@ beforeEach(() => {
});
describe("Coach links follow the Coach's capability", () => {
- it("the sources pointer links to the Coach while it is available", () => {
+ it("the sources pointer opens the Coach's settings on what it can see", () => {
expect(render()).toContain(
- 'href="/coach"',
+ 'href="/coach?settings=data"',
);
});
it("the sources pointer offers no dead link without the Coach", () => {
coachState.available = false;
const html = render();
- expect(html).not.toContain('href="/coach"');
+ expect(html).not.toContain('href="/coach?settings=data"');
expect(html).not.toContain("coach-prefs-sources-pointer");
});
diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx
index 7e12ed1e5..dfced9eb9 100644
--- a/src/components/settings/coach-prefs-section.tsx
+++ b/src/components/settings/coach-prefs-section.tsx
@@ -223,8 +223,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
- {/* Data clusters + analysis window live on ONE owner: the
- sources rail inside the chat ("What I draw on"). This card
+ {/* Data clusters + analysis window live on ONE owner: "What I
+ can see" behind the Coach's settings gear, which
+ `/coach?settings=data` opens directly. This card
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
@@ -237,7 +238,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
>
{t("insights.coach.settingsSourcesPointer")}{" "}
{t("insights.coach.settingsSourcesPointerLink")}
diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx
index c25175db8..b1a7f2a00 100644
--- a/src/components/ui/responsive-sheet.tsx
+++ b/src/components/ui/responsive-sheet.tsx
@@ -88,6 +88,13 @@ export interface ResponsiveSheetProps {
* default, so the width now resolves from this single source.
*/
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl" | "6xl";
+ /**
+ * Where focus goes when the surface closes. Radix returns it to a
+ * `Dialog.Trigger`, and this primitive has none, so a caller whose opener
+ * should get focus back (a gear, a menu button) hands it over here: call
+ * `event.preventDefault()` and focus the opener.
+ */
+ onCloseAutoFocus?: (event: Event) => void;
children: React.ReactNode;
}
@@ -136,6 +143,7 @@ export function ResponsiveSheet({
bodyClassName,
showCloseButton = true,
contentWidth = "md",
+ onCloseAutoFocus,
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
@@ -144,6 +152,7 @@ export function ResponsiveSheet({
return (
= {}) {
+ const map = new Map(Object.entries(initial));
+ return {
+ getItem: (key: string) => map.get(key) ?? null,
+ setItem: (key: string, value: string) => void map.set(key, value),
+ map,
+ };
+}
+
+const throwing = {
+ getItem: () => {
+ throw new Error("SecurityError");
+ },
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+};
+
+describe("coach panel open preference", () => {
+ it("opens the docked panel on a first visit", () => {
+ const storage = memoryStorage();
+ expect(readCoachPanelPreference(storage)).toBeNull();
+ expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
+ });
+
+ it("remembers a closed panel, and an opened one, across reads", () => {
+ const storage = memoryStorage();
+ expect(writeCoachPanelPreference(storage, false)).toBe(true);
+ expect(storage.map.get(COACH_PANEL_STORAGE_KEY)).toBe("false");
+ expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(
+ false,
+ );
+ writeCoachPanelPreference(storage, true);
+ expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
+ });
+
+ it("treats an unknown stored value as no choice", () => {
+ const storage = memoryStorage({ [COACH_PANEL_STORAGE_KEY]: "maybe" });
+ expect(readCoachPanelPreference(storage)).toBeNull();
+ });
+
+ it("falls back to the default when storage throws or is absent", () => {
+ expect(readCoachPanelPreference(throwing)).toBeNull();
+ expect(writeCoachPanelPreference(throwing, false)).toBe(false);
+ expect(readCoachPanelPreference(null)).toBeNull();
+ expect(writeCoachPanelPreference(undefined, true)).toBe(false);
+ });
+});
diff --git a/src/hooks/use-coach-panel-open.ts b/src/hooks/use-coach-panel-open.ts
new file mode 100644
index 000000000..280d0d34f
--- /dev/null
+++ b/src/hooks/use-coach-panel-open.ts
@@ -0,0 +1,135 @@
+"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 | 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 | 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 };
+}
diff --git a/src/lib/insights/__tests__/coach-conversation-groups.test.ts b/src/lib/insights/__tests__/coach-conversation-groups.test.ts
new file mode 100644
index 000000000..5b5116e9c
--- /dev/null
+++ b/src/lib/insights/__tests__/coach-conversation-groups.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+
+import { groupConversationsByRecency } from "../coach-conversation-groups";
+
+// The suite runs with TZ=UTC, so local day boundaries are UTC midnights.
+const NOW = new Date("2026-10-04T15:00:00.000Z");
+
+function c(id: string, updatedAt: string) {
+ return { id, updatedAt };
+}
+
+describe("groupConversationsByRecency", () => {
+ it("buckets by local day: today, yesterday, the six days before, earlier", () => {
+ const groups = groupConversationsByRecency(
+ [
+ c("a", "2026-10-04T00:00:00.000Z"), // today, at midnight
+ c("b", "2026-10-03T23:59:59.000Z"), // yesterday, last second
+ c("c", "2026-10-03T00:00:00.000Z"), // yesterday, first second
+ c("d", "2026-09-28T00:00:00.000Z"), // six days before today
+ c("e", "2026-09-27T23:59:59.000Z"), // one second older: earlier
+ ],
+ NOW,
+ );
+ expect(groups.map((g) => [g.id, g.conversations.map((x) => x.id)])).toEqual(
+ [
+ ["today", ["a"]],
+ ["yesterday", ["b", "c"]],
+ ["thisWeek", ["d"]],
+ ["earlier", ["e"]],
+ ],
+ );
+ });
+
+ it("drops empty groups and keeps the caller's order inside a group", () => {
+ const groups = groupConversationsByRecency(
+ [c("x", "2026-01-02T00:00:00.000Z"), c("y", "2026-03-02T00:00:00.000Z")],
+ NOW,
+ );
+ expect(groups).toHaveLength(1);
+ expect(groups[0].id).toBe("earlier");
+ expect(groups[0].labelKey).toBe("insights.coach.history.groupEarlier");
+ expect(groups[0].conversations.map((x) => x.id)).toEqual(["x", "y"]);
+ });
+
+ it("returns nothing for an empty list", () => {
+ expect(groupConversationsByRecency([], NOW)).toEqual([]);
+ });
+});
diff --git a/src/lib/insights/coach-conversation-groups.ts b/src/lib/insights/coach-conversation-groups.ts
new file mode 100644
index 000000000..44d24ac6d
--- /dev/null
+++ b/src/lib/insights/coach-conversation-groups.ts
@@ -0,0 +1,58 @@
+/**
+ * Bucket Coach conversations by how recently they were last touched: Today,
+ * Yesterday, This week (the six days before yesterday), Earlier.
+ *
+ * Shared by the Coach page's conversations panel and the standalone
+ * conversation list, so both group a thread the same way. Comparisons run
+ * against LOCAL day boundaries, so "Today" follows the person's own midnight
+ * rather than UTC's. Empty groups are dropped; order inside a group is the
+ * caller's (the server already returns `updatedAt desc`).
+ */
+export type CoachRecencyGroupId =
+ "today" | "yesterday" | "thisWeek" | "earlier";
+
+export interface CoachRecencyGroup {
+ id: CoachRecencyGroupId;
+ labelKey: string;
+ conversations: T[];
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+const GROUPS: ReadonlyArray<{ id: CoachRecencyGroupId; labelKey: string }> = [
+ { id: "today", labelKey: "insights.coach.history.groupToday" },
+ { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
+ { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
+ { id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
+];
+
+export function groupConversationsByRecency(
+ conversations: readonly T[],
+ now: Date = new Date(),
+): CoachRecencyGroup[] {
+ const startOfToday = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ ).getTime();
+ const startOfYesterday = startOfToday - DAY_MS;
+ const startOfWeek = startOfToday - 6 * DAY_MS;
+
+ const buckets: Record = {
+ today: [],
+ yesterday: [],
+ thisWeek: [],
+ earlier: [],
+ };
+ for (const conversation of conversations) {
+ const updated = new Date(conversation.updatedAt).getTime();
+ if (updated >= startOfToday) buckets.today.push(conversation);
+ else if (updated >= startOfYesterday) buckets.yesterday.push(conversation);
+ else if (updated >= startOfWeek) buckets.thisWeek.push(conversation);
+ else buckets.earlier.push(conversation);
+ }
+
+ return GROUPS.map((g) => ({ ...g, conversations: buckets[g.id] })).filter(
+ (g) => g.conversations.length > 0,
+ );
+}