From 12153fe143d68be1587f28674df3dbd0a7fe4586 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 4 Oct 2026 20:00:07 +0200
Subject: [PATCH] feat(coach): move conversations into a collapsible right
panel
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.
---
messages/de.json | 21 +-
messages/en.json | 21 +-
messages/es.json | 21 +-
messages/fr.json | 21 +-
messages/it.json | 21 +-
messages/ko.json | 21 +-
messages/pl.json | 21 +-
.../coach-stream-lifecycle-guard.test.ts | 20 +-
src/app/coach/conversations/page.tsx | 56 +--
src/app/coach/page-client.tsx | 23 ++
.../coach-conversation-deeplink.test.tsx | 16 +-
.../__tests__/coach-input.test.tsx | 46 +--
.../__tests__/coach-model-picker.test.tsx | 121 ++++++
.../__tests__/coach-settings-overlay.test.tsx | 117 ++++++
.../__tests__/history-rail.test.tsx | 35 +-
.../coach-panel/coach-conversation.tsx | 188 +++++-----
.../insights/coach-panel/coach-input.tsx | 95 +----
.../coach-panel/coach-model-picker.tsx | 351 ++++++++++++++++++
.../coach-panel/coach-settings-body.tsx | 76 ++++
.../coach-panel/coach-settings-overlay.tsx | 129 +++++++
.../coach-panel/conversation-rename.tsx | 37 +-
.../coach-panel/conversations-panel.tsx | 288 ++++++++++++++
.../insights/coach-panel/history-rail.tsx | 348 ++++++++++-------
.../layout/__tests__/top-bar-actions.test.tsx | 28 ++
src/components/layout/top-bar-actions.tsx | 53 +++
src/components/layout/top-bar.tsx | 7 +
.../coach-links-without-coach.test.tsx | 6 +-
.../settings/coach-prefs-section.tsx | 7 +-
src/components/ui/responsive-sheet.tsx | 10 +
.../__tests__/use-coach-panel-open.test.ts | 57 +++
src/hooks/use-coach-panel-open.ts | 135 +++++++
.../coach-conversation-groups.test.ts | 48 +++
src/lib/insights/coach-conversation-groups.ts | 58 +++
33 files changed, 2074 insertions(+), 428 deletions(-)
create mode 100644 src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx
create mode 100644 src/components/insights/coach-panel/coach-model-picker.tsx
create mode 100644 src/components/insights/coach-panel/coach-settings-body.tsx
create mode 100644 src/components/insights/coach-panel/coach-settings-overlay.tsx
create mode 100644 src/components/insights/coach-panel/conversations-panel.tsx
create mode 100644 src/components/layout/__tests__/top-bar-actions.test.tsx
create mode 100644 src/components/layout/top-bar-actions.tsx
create mode 100644 src/hooks/__tests__/use-coach-panel-open.test.ts
create mode 100644 src/hooks/use-coach-panel-open.ts
create mode 100644 src/lib/insights/__tests__/coach-conversation-groups.test.ts
create mode 100644 src/lib/insights/coach-conversation-groups.ts
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,
+ );
+}