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 `