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( - /]*data-slot="coach-input-actions"[^>]*>/, + expect(html).not.toContain('data-slot="coach-input-actions"'); + expect(html).not.toContain("coach-input-action-new-chat"); + expect(html).not.toContain("coach-input-action-history"); + expect(html).not.toContain("coach-input-action-settings"); + expect(html).not.toContain('href="/settings/ai"'); + // The attach control stays, at the 44 px tap-target floor on phones. + const attach = html.match( + /]*data-slot="coach-input-attach"[^>]*>/, ); - expect(actions?.[0]).toMatch(/\bsize-11\b/); - expect(actions?.[0]).toMatch(/\bsm:size-9\b/); + expect(attach?.[0]).toMatch(/\bsize-11\b/); + expect(attach?.[0]).toMatch(/\bsm:size-9\b/); }); it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx new file mode 100644 index 000000000..91d922e95 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import type { + ProviderChainData, + UserAIProvider, +} from "@/components/settings/ai/shared"; + +import { + chainWithFirst, + modelControlFor, + reasoningFieldFor, + selectableProviders, +} from "../coach-model-picker"; + +const CHAIN: ProviderChainData["configuredChain"] = [ + { providerType: "codex", enabled: true, available: false }, + { providerType: "openai", enabled: true, available: true }, + { providerType: "anthropic", enabled: false, available: true }, + { providerType: "local", enabled: true, available: true }, + { providerType: "admin-openai", enabled: true, available: true }, +]; + +function userProvider(over: Partial = {}): UserAIProvider { + return { + provider: "OPENAI", + model: "gpt-4o", + baseUrl: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: true, + openaiKeyPreview: "sk-…abcd", + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + localReasoningEffort: null, + compatReasoningEffort: null, + ...over, + }; +} + +describe("chainWithFirst", () => { + it("moves the chosen provider to the front and keeps the rest in order", () => { + expect(chainWithFirst(CHAIN, "local")).toEqual([ + { providerType: "local", priority: 1, enabled: true }, + { providerType: "codex", priority: 2, enabled: true }, + { providerType: "openai", priority: 3, enabled: true }, + { providerType: "anthropic", priority: 4, enabled: false }, + { providerType: "admin-openai", priority: 5, enabled: true }, + ]); + }); + + it("never sends a reasoning setting, so the server keeps the stored one", () => { + for (const entry of chainWithFirst(CHAIN, "openai")) { + expect(Object.keys(entry).sort()).toEqual([ + "enabled", + "priority", + "providerType", + ]); + } + }); +}); + +describe("selectableProviders", () => { + it("offers only enabled providers that can answer", () => { + expect(selectableProviders(CHAIN)).toEqual([ + "openai", + "local", + "admin-openai", + ]); + }); +}); + +describe("modelControlFor", () => { + it("offers the presets for the provider whose key the person saved", () => { + const control = modelControlFor("openai", userProvider()); + expect(control.kind).toBe("preset"); + if (control.kind === "preset") expect(control.value).toBe("gpt-4o"); + }); + + it("does not offer a model for a provider the shared model column is not set up for", () => { + // The column belongs to OpenAI here; a Claude model name written for the + // Anthropic entry would be sent to OpenAI. + expect(modelControlFor("anthropic", userProvider()).kind).toBe("managed"); + }); + + it("keeps the operator's provider and Codex read-only", () => { + expect(modelControlFor("admin-openai", userProvider()).kind).toBe( + "managed", + ); + expect(modelControlFor("codex", userProvider()).kind).toBe("managed"); + expect(modelControlFor(null, userProvider()).kind).toBe("managed"); + }); + + it("gives the gateway its own free-text model", () => { + expect( + modelControlFor( + "openai-compatible", + userProvider({ compatModel: "qwen3:32b" }), + ), + ).toEqual({ kind: "gateway", value: "qwen3:32b" }); + }); +}); + +describe("reasoningFieldFor", () => { + it("writes the Local and gateway entries' own reasoning fields", () => { + expect( + reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })), + ).toEqual({ field: "localReasoningEffort", value: "low" }); + expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({ + field: "compatReasoningEffort", + value: null, + }); + }); + + it("has nothing for providers without a reasoning setting", () => { + expect(reasoningFieldFor("openai", userProvider())).toBeNull(); + expect(reasoningFieldFor("codex", userProvider())).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx new file mode 100644 index 000000000..2441bfc05 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; +import type { + ProviderChainData, + UserAIProvider, +} from "@/components/settings/ai/shared"; + +import { CoachSettingsOverlay } from "../coach-settings-overlay"; +import { + COACH_SETTINGS_DATA_SECTION_ID, + CoachSettingsBody, +} from "../coach-settings-body"; + +const CHAIN: ProviderChainData = { + activeProvider: "local", + cachedActiveProvider: "local", + configuredChain: [ + { providerType: "codex", enabled: true, available: false }, + { providerType: "local", enabled: true, available: true }, + { providerType: "admin-openai", enabled: true, available: true }, + ], +}; + +const PROVIDER: UserAIProvider = { + provider: "LOCAL", + model: "qwen2.5", + baseUrl: "http://localhost:11434/v1", + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + localReasoningEffort: "none", + compatReasoningEffort: null, +}; + +function render(node: React.ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(queryKeys.insightsProviderChain(), CHAIN); + client.setQueryData(queryKeys.userAiProvider(), PROVIDER); + client.setQueryData(queryKeys.coachPrefs(), DEFAULT_COACH_PREFS); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("", () => { + it("holds the model choice, what the Coach sees, and a way to the full settings", () => { + const html = render(); + expect(html).toContain('data-slot="coach-settings-model"'); + expect(html).toContain('data-slot="coach-model-picker"'); + expect(html).toContain(`id="${COACH_SETTINGS_DATA_SECTION_ID}"`); + // "What I can see" is the existing rail, wired to the saved preference: + // the lookback control and the data-area switches. + expect(html).toContain('data-slot="coach-sources-window"'); + expect(html).toContain('data-slot="coach-sources-list"'); + expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings { + const html = render(); + const select = html.match( + /]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/, + )?.[0]; + expect(select).toBeDefined(); + expect(select).toContain('value="local"'); + expect(select).toContain('value="admin-openai"'); + expect(select).not.toContain('value="codex"'); + }); + + it("shows the model and reasoning controls the active provider owns", () => { + const html = render(); + expect(html).toContain('data-slot="coach-quick-model"'); + expect(html).toContain('id="coach-quick-reasoning"'); + // Keys, base URLs and the chain order are not here. + expect(html).not.toContain("http://localhost:11434/v1"); + expect(html).not.toMatch(/type="password"/); + }); + + it("names the data section so a deep link can land on it", () => { + const html = render(); + expect(html).toMatch( + new RegExp( + `]*id="${COACH_SETTINGS_DATA_SECTION_ID}"[^>]*tabindex="-1"`, + ), + ); + }); +}); + +describe("", () => { + it("renders a labelled gear that announces a dialog and its state", () => { + const html = render( + {}} />, + ); + const gear = html.match( + /]*data-slot="coach-settings"[^>]*>/, + )?.[0]; + expect(gear).toContain('aria-label="Coach settings"'); + expect(gear).toContain('aria-haspopup="dialog"'); + expect(gear).toContain('aria-expanded="false"'); + // Closed: no popover content in the markup. + expect(html).not.toContain('data-slot="coach-settings-popover"'); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx index 224f360fe..1dea34a3d 100644 --- a/src/components/insights/coach-panel/__tests__/history-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/history-rail.test.tsx @@ -166,25 +166,46 @@ describe("", () => { expect(html).toContain("Deine Unterhaltungen erscheinen hier"); }); - it("renders the delete button for each conversation row", () => { + it("gives each row one labelled actions menu instead of inline buttons", () => { const client = makeClientWithConversations(samplePage); const html = render( {}} />, client, ); - const buttons = html.match(/data-slot="coach-history-delete"/g) ?? []; - expect(buttons.length).toBe(2); + const menus = + html.match(/]*data-slot="coach-history-row-menu"[^>]*>/g) ?? []; + expect(menus.length).toBe(2); + expect(menus[0]).toContain('aria-haspopup="menu"'); + expect(html).toContain( + 'aria-label="Actions for Why was BP higher on Monday?"', + ); + // Rename and Delete sit inside the menu (portalled on open), so the row + // itself carries neither a pencil nor a trash button any more. + expect(html).not.toContain('data-slot="coach-conversation-rename"'); + expect(html).not.toContain('data-slot="coach-history-delete"'); }); - it("renders an accessible rename button for each conversation row", () => { + it("marks the open conversation with aria-current", () => { + const client = makeClientWithConversations(samplePage); + const html = render( + {}} />, + client, + ); + const current = html.match(/]*aria-current="true"[^>]*>/g) ?? []; + expect(current).toHaveLength(1); + expect(current[0]).toContain('title="Compare this week to last month"'); + }); + + it("groups the rows under recency headings", () => { const client = makeClientWithConversations(samplePage); const html = render( {}} />, client, ); - const buttons = html.match(/data-slot="coach-conversation-rename"/g) ?? []; - expect(buttons.length).toBe(2); - expect(html).toContain('aria-label="Rename conversation"'); + // Both sample threads are months old, so they share the Earlier group. + expect(html.match(/data-slot="coach-history-group"/g)).toHaveLength(1); + expect(html).toContain('data-group="earlier"'); + expect(html).toMatch(/]*>Earlier<\/h3>/); }); // v1.30.2 (QoL H1) — infinite history: reachability beyond the first page. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index a0ac9c1dd..ff6265743 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,15 +1,13 @@ "use client"; import { useEffect, useReducer, useRef, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useMutation, useQueries, useQuery, useQueryClient, } from "@tanstack/react-query"; -import { Paperclip, Plus, Settings, Sparkles } from "lucide-react"; +import { Paperclip, Plus, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -56,6 +54,8 @@ import { type InterleavedThreadItem, } from "./message-thread"; import { CoachClarificationCard } from "./clarification-card"; +import { CoachSettingsOverlay } from "./coach-settings-overlay"; +import { ConversationsPanel } from "./conversations-panel"; import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; @@ -260,6 +260,23 @@ export interface CoachConversationProps { * the surface-toggle preserves scope. The page omits it. */ registerConversationIdGetter?: (getter: () => string | null) => void; + /** + * Page surface only: `/coach?settings=data` (the link from Settings → + * Coach) opens the settings gear on "What I can see". Read once, on mount. + */ + openSettingsOnData?: boolean; +} + +/** + * The page's URL for a conversation: `/coach?c=`, or bare `/coach` for a + * new chat. Written with `history.replaceState` (the App Router keeps + * `useSearchParams` in step with it) so a reload lands on the same thread + * without a server round trip or a new history entry per switch. + */ +function coachPageHref(conversationId: string | null): string { + return conversationId + ? `/coach?c=${encodeURIComponent(conversationId)}` + : "/coach"; } export function CoachConversation({ @@ -280,9 +297,9 @@ export function CoachConversation({ initialDocumentId, initialWorkoutId, registerConversationIdGetter, + openSettingsOnData = false, }: CoachConversationProps) { const { t } = useTranslations(); - const router = useRouter(); const [currentConversationId, setCurrentConversationId] = useState< string | null @@ -292,6 +309,7 @@ export function CoachConversation({ ); const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); + const [drawerSettingsOpen, setDrawerSettingsOpen] = useState(false); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); // v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet // offer to fold a clarifying-question answer back into the @@ -354,9 +372,19 @@ export function CoachConversation({ const attachDoc = useAttachCoachDocument(currentConversationId ?? ""); const detachDoc = useDetachCoachDocument(currentConversationId ?? ""); + // Page surface: keep `?c=` in step with the open thread, so a reload (or + // a shared tab) lands on it. The drawer has no URL of its own. + function syncPageUrl(conversationId: string | null) { + if (surface !== "page" || typeof window === "undefined") return; + const href = coachPageHref(conversationId); + if (window.location.pathname + window.location.search === href) return; + window.history.replaceState(window.history.state, "", href); + } + const send = useSendCoachMessage({ onDone: (resolvedId) => { setCurrentConversationId(resolvedId); + syncPageUrl(resolvedId); }, }); @@ -791,6 +819,27 @@ export function CoachConversation({ setActiveWorkoutId(null); dispatchGuided({ type: "RESET" }); send.reset(); + syncPageUrl(null); + } + + // Open a stored conversation. Shared by the page's panel and the drawer's + // history tray, so a switch resets the same state on both surfaces. + function selectConversation(id: string) { + setCurrentConversationId(id); + setHistoryTrayOpen(false); + setPendingAdopt(null); + // v1.29.x (S7) — the selected thread's own `fenced` flag + `attachments` + // are 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` 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(); + syncPageUrl(id); } const title = conversation?.title ?? t("insights.coach.newChat"); @@ -840,10 +889,6 @@ export function CoachConversation({ // forking any composer logic (dictation, auto-grow, send/stop, the // guided-question placeholder all behave identically in both spots). // - // v1.18.11 (W11) — on the page surface the composer is the control hub: - // it grows a leading `+` actions menu (new chat + open conversations) and - // a settings deep-link. The drawer keeps its own header for those, so the - // hub is page-only. // v1.29.x (S7) — the composer with its attachment chrome: the pills row + the // "still indexing" hint stack directly above the input, so they travel with // the composer into BOTH the new-chat hero and the docked conversation view. @@ -894,9 +939,6 @@ export function CoachConversation({ ? t("insights.coach.guided.answerPlaceholder") : undefined } - showHub={surface === "page"} - onNewChat={handleNewChat} - onOpenHistory={() => router.push("/coach/conversations")} attachEnabled={attachEnabled} onPickFromVault={() => setPickerOpen(true)} onUploadNew={handleUploadNew} @@ -1140,50 +1182,56 @@ export function CoachConversation({ ) : 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 ? (
-
- {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 ( +
+

+ {t("common.loadFailed")} +

+ +
+ ); + } + + if (!chain || !userProvider) { + return ( +

+

+ ); + } + + return ( +
+
+ + { + const next = e.target.value as ProviderType; + if (next && next !== active) switchProvider.mutate(next); + }} + > + {active === null ? ( + + ) : null} + {options.map((p) => ( + + ))} + +
+ + {model.kind === "preset" ? ( +
+ + + patchProvider.mutate({ model: e.target.value || null }) + } + > + + {model.value && !model.presets.includes(model.value) ? ( + + ) : null} + {model.presets.map((m) => ( + + ))} + +
+ ) : model.kind === "gateway" ? ( + patchProvider.mutate({ compatModel: next })} + /> + ) : ( +

+ {t("insights.coach.frame.modelManaged")} +

+ )} + + {reasoning ? ( + patchProvider.mutate({ [reasoning.field]: next })} + /> + ) : null} + + {busy ? ( +

+ {t("insights.coach.frame.saving")} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +/** 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 ( +
{ + e.preventDefault(); + if (changed) onSave(draft.trim() || null); + }} + > + +
+ setDraft(e.target.value)} + disabled={disabled} + className="min-w-0 flex-1" + /> + +
+
+ ); +} 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 = ( + + ); + + 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 ( @@ -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) => ( +
+ {inSheet ? ( + + {t("insights.coach.historyTitle")} + + ) : ( +

+ {t("insights.coach.historyTitle")} +

+ )} + + { + setSettingsOpen(next); + if (!next) setSettingsOnData(false); + }} + focusData={settingsOnData} + /> + {inSheet ? ( + + + + ) : null} +
+ ); + + const body = (inSheet: boolean) => ( + <> + {header(inSheet)} + { + onSelect(id); + afterPick(); + }} + deletion={deletion} + onDeleteActive={() => onNewChat()} + onUndoDeleteActive={(id) => onSelect(id)} + className="min-h-0 flex-1" + /> + + + ); + + return ( + <> + + + + + {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 `