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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 20:00:07 +02:00
1 parent 1cb50ed21d
commit 12153fe143
33 files changed
+2074 -428

No files matched your search

+18 -3
View File
@@ -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",
+18 -3
View File
@@ -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",
+18 -3
View File
@@ -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",
+18 -3
View File
@@ -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",
+18 -3
View File
@@ -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",
+18 -3
View File
@@ -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}분 전",
+18 -3
View File
@@ -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",
@@ -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(
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
/function selectConversation\(id: string\) \{[\s\S]*?\n {2}\}/,
);
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
const onSelectBody = match![0];
expect(match, "selectConversation handler not found").not.toBeNull();
expect(
onSelectBody.includes("send.reset()"),
match![0].includes("send.reset()"),
"selecting another conversation must call send.reset() so the previous " +
"thread's trailing stream state does not bleed into the new thread",
).toBe(true);
const selectSites = source.match(/onSelect=\{([^}]*)\}/g) ?? [];
expect(selectSites.length).toBeGreaterThanOrEqual(2);
for (const site of selectSites) {
expect(
site,
"every conversation picker routes through selectConversation",
).toBe("onSelect={selectConversation}");
}
});
it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => {
+2 -54
View File
@@ -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<RecencyGroupId, CoachConversationDTO[]> = {
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<string | null>(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
+23
View File
@@ -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}
/>
);
}
@@ -158,16 +158,16 @@ describe("<CoachConversation> 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(<CoachConversation surface="page" />, 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"');
});
});
@@ -229,10 +229,9 @@ describe("<CoachInput>", () => {
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(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
@@ -251,26 +250,25 @@ describe("<CoachInput>", () => {
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(
/<div[^>]*data-slot="coach-input-controls"[^>]*>/,
@@ -295,22 +293,28 @@ describe("<CoachInput>", () => {
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(
<CoachInput
value=""
onChange={() => {}}
onSubmit={() => {}}
showHub
onNewChat={() => {}}
onOpenHistory={() => {}}
attachEnabled
onPickFromVault={() => {}}
onUploadNew={() => {}}
/>,
);
const actions = html.match(
/<button[^>]*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(
/<button[^>]*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)", () => {
@@ -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> = {}): 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();
});
});
@@ -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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<CoachSettingsBody>", () => {
it("holds the model choice, what the Coach sees, and a way to the full settings", () => {
const html = render(<CoachSettingsBody />);
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</);
});
it("offers only the providers that can answer, with the active one selected", () => {
const html = render(<CoachSettingsBody />);
const select = html.match(
/<select[^>]*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(<CoachSettingsBody />);
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(<CoachSettingsBody />);
expect(html).toMatch(
new RegExp(
`<section[^>]*id="${COACH_SETTINGS_DATA_SECTION_ID}"[^>]*tabindex="-1"`,
),
);
});
});
describe("<CoachSettingsOverlay>", () => {
it("renders a labelled gear that announces a dialog and its state", () => {
const html = render(
<CoachSettingsOverlay open={false} onOpenChange={() => {}} />,
);
const gear = html.match(
/<button[^>]*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"');
});
});
@@ -166,25 +166,46 @@ describe("<HistoryRail>", () => {
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(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
const buttons = html.match(/data-slot="coach-history-delete"/g) ?? [];
expect(buttons.length).toBe(2);
const menus =
html.match(/<button[^>]*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(
<HistoryRail activeId="c2" onSelect={() => {}} />,
client,
);
const current = html.match(/<button[^>]*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(
<HistoryRail activeId={null} onSelect={() => {}} />,
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(/<h3[^>]*>Earlier<\/h3>/);
});
// v1.30.2 (QoL H1) — infinite history: reachability beyond the first page.
@@ -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=<id>`, 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({
</div>
) : 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 (
<div
data-slot="coach-conversation"
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
className={cn("flex min-h-0 flex-1 flex-row", className)}
>
{docScopeBanner}
{!heroActive ? <h1 className="sr-only">{title}</h1> : 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 ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
/>
</div>
{/* 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. */}
<div
data-slot="coach-page-composer"
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
>
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
</div>
</>
)}
{/* 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. */}
<div
data-slot="coach-page-main"
className="flex min-h-0 min-w-0 flex-1 flex-col"
>
{docScopeBanner}
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
/>
</div>
<div
data-slot="coach-page-composer"
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
>
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
</div>
</>
)}
</div>
<ConversationsPanel
activeId={currentConversationId}
onSelect={selectConversation}
onNewChat={handleNewChat}
openSettingsOnData={openSettingsOnData}
/>
{attachEnabled ? (
<AttachmentPicker
open={pickerOpen}
@@ -1249,24 +1297,13 @@ export function CoachConversation({
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{/* v1.16.1 — the Coach preferences moved into Settings → AI
(one place for model + behaviour). The header keeps a gear
that deep-links there instead of opening an in-chat sheet. */}
<Button
asChild
variant="ghost"
size="icon"
data-slot="coach-settings"
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
{/* The same quick settings the page's panel opens: model and what
the Coach may read. Everything else stays in Settings → AI. */}
<CoachSettingsOverlay
open={drawerSettingsOpen}
onOpenChange={setDrawerSettingsOpen}
className="sm:size-11"
/>
{trailingHeaderActions}
</header>
@@ -1299,22 +1336,7 @@ export function CoachConversation({
historyRail={
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => {
setCurrentConversationId(id);
setHistoryTrayOpen(false);
setPendingAdopt(null);
// v1.29.x (S7) — the selected thread's own `fenced` flag +
// `attachments` are now authoritative; drop any staged `?doc=`
// seed so a pending attachment can't leak across a thread switch.
setPendingAttachmentIds([]);
dispatchGuided({ type: "RESET" });
// Drop the finished stream's trailing state, as `handleNewChat`
// already does. Without it the previous thread's last assistant
// or error bubble renders appended to the newly selected thread
// — `streamingActive` stays true because that messageId is
// absent from the new thread's messages — until the next send.
send.reset();
}}
onSelect={selectConversation}
/>
}
sourcesOpen={sourcesTrayOpen}
@@ -7,17 +7,12 @@ import {
useEffect,
useRef,
} from "react";
import Link from "next/link";
import {
FolderOpen,
Loader2,
MessagesSquare,
Paperclip,
Plus,
Send,
Settings,
Square,
Target,
Upload,
} from "lucide-react";
@@ -55,10 +50,8 @@ import { COACH_COMPOSER_ID } from "./composer-focus";
* the guard stack (system prompt, prose-grounding check, outbound
* refusal), not a rendered disclaimer line.
*
* v1.18.11 (W11): the composer is the conversation's control hub on the
* full-page Coach surface. When `showHub` is set it includes a `+` actions menu
* for a new chat or conversation history. The drawer omits `showHub` because
* its header already carries those actions.
* New chat, the conversation list and the settings gear live in the Coach
* page's conversations panel (and the drawer's header), not in the composer.
*
* Voice input was removed after repeated browser and permission failures made
* the control unreliable. The composer now exposes only actions that work
@@ -95,28 +88,12 @@ export interface CoachInputProps {
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
/**
* v1.18.11 — mount the control-hub action row (leading `+` menu +
* settings link) inside the composer card. The page surface sets this;
* the drawer leaves it off and keeps the single-row composer.
*/
showHub?: boolean;
/**
* v1.18.11 — start a fresh conversation. Wired into the `+` actions
* menu. Required when `showHub` is set.
*/
onNewChat?: () => void;
/**
* v1.18.11 — open the left conversation-history drawer. Wired into the
* `+` actions menu. Required when `showHub` is set.
*/
onOpenHistory?: () => void;
/**
* v1.29.x (S7) — mount the document-attach trigger (a leading paperclip menu:
* "Choose from documents" + "Upload new document"). Set by the parent only
* when the `inboundDocuments` module is enabled for the user; hidden entirely
* otherwise. Separate from the `showHub` `+` actions menu — this affordance
* stages fenced document attachments, it does not manage conversations.
* otherwise. It stages fenced document attachments; conversations are managed
* in the page's conversations panel.
*/
attachEnabled?: boolean;
/**
@@ -177,9 +154,6 @@ export function CoachInput({
inputId = COACH_COMPOSER_ID,
autoFocusOnOpen = false,
placeholder,
showHub = false,
onNewChat,
onOpenHistory,
attachEnabled = false,
onPickFromVault,
onUploadNew,
@@ -293,61 +267,10 @@ export function CoachInput({
</Button>
);
// Leading `+` actions menu for the full-page Coach surface. The settings
// shortcut remains in this menu while the dedicated gear stays in the page
// toolbar.
const actionsButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-plans">
<Link href="/coach/plans">
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
<Link href="/settings/ai">
<Settings className="size-4" aria-hidden="true" />
{t("insights.coach.settings")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
// v1.29.x (S7) — the document-attach affordance: a leading paperclip menu
// ("Choose from documents" opens the vault picker; "Upload new document"
// opens the file input, reusing the existing `/api/documents/inbound`
// pipeline). Rendered only when the parent enables it (module-gated). It is a
// SEPARATE control from the `showHub` `+` actions menu — the two never merge.
// pipeline). Rendered only when the parent enables it (module-gated).
const attachButton = attachEnabled ? (
<>
<input
@@ -474,14 +397,8 @@ export function CoachInput({
className="flex shrink-0 items-center gap-1.5"
>
{attachButton}
{showHub ? actionsButton : null}
</div>
<div
data-slot={showHub ? "coach-input-hub" : undefined}
className="flex shrink-0 items-center gap-1.5"
>
{sendButton}
</div>
<div className="flex shrink-0 items-center gap-1.5">{sendButton}</div>
</div>
</div>
</form>
@@ -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<Record<ProviderType, readonly string[]>> = {
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<ProviderChainData>("/api/insights/provider-chain"),
});
const providerQuery = useQuery({
queryKey: queryKeys.userAiProvider(),
queryFn: () => apiGet<UserAIProvider>("/api/user/ai-provider"),
});
const [error, setError] = useState<string | null>(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<string, unknown>) =>
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 (
<div className="flex flex-col items-start gap-2 text-sm">
<p role="alert" className="text-destructive">
{t("common.loadFailed")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void chainQuery.refetch();
void providerQuery.refetch();
}}
>
{t("common.retry")}
</Button>
</div>
);
}
if (!chain || !userProvider) {
return (
<p
role="status"
className="text-muted-foreground flex items-center gap-2 text-sm"
>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("common.loading")}
</p>
);
}
return (
<div data-slot="coach-model-picker" className="flex flex-col gap-4">
<div>
<Label htmlFor="coach-quick-provider">
{t("insights.coach.frame.provider")}
</Label>
<NativeSelect
id="coach-quick-provider"
data-slot="coach-quick-provider"
className="mt-1"
value={active ?? ""}
disabled={busy || options.length === 0}
onChange={(e) => {
const next = e.target.value as ProviderType;
if (next && next !== active) switchProvider.mutate(next);
}}
>
{active === null ? (
<option value="" disabled>
{t("insights.coach.frame.noProvider")}
</option>
) : null}
{options.map((p) => (
<option key={p} value={p}>
{t(`settings.ai.providerChain.types.${p}`)}
</option>
))}
</NativeSelect>
</div>
{model.kind === "preset" ? (
<div>
<Label htmlFor="coach-quick-model">
{t("settings.ai.modelLabel")}
</Label>
<NativeSelect
id="coach-quick-model"
data-slot="coach-quick-model"
className="mt-1"
value={model.value ?? ""}
disabled={busy}
onChange={(e) =>
patchProvider.mutate({ model: e.target.value || null })
}
>
<option value="">{t("settings.ai.modelOptionDefault")}</option>
{model.value && !model.presets.includes(model.value) ? (
<option value={model.value}>{model.value}</option>
) : null}
{model.presets.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</NativeSelect>
</div>
) : model.kind === "gateway" ? (
<GatewayModelField
key={model.value ?? ""}
value={model.value}
disabled={busy}
onSave={(next) => patchProvider.mutate({ compatModel: next })}
/>
) : (
<p
data-slot="coach-quick-model-managed"
className="text-muted-foreground text-xs"
>
{t("insights.coach.frame.modelManaged")}
</p>
)}
{reasoning ? (
<ReasoningEffortField
id="coach-quick-reasoning"
value={reasoning.value}
onChange={(next) => patchProvider.mutate({ [reasoning.field]: next })}
/>
) : null}
{busy ? (
<p role="status" className="text-muted-foreground text-xs">
{t("insights.coach.frame.saving")}
</p>
) : null}
{error ? (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
/** 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 (
<form
className="flex flex-col gap-1"
onSubmit={(e) => {
e.preventDefault();
if (changed) onSave(draft.trim() || null);
}}
>
<Label htmlFor="coach-quick-gateway-model">
{t("settings.ai.modelLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="coach-quick-gateway-model"
data-slot="coach-quick-gateway-model"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={disabled}
className="min-w-0 flex-1"
/>
<Button
type="submit"
size="sm"
variant="outline"
disabled={disabled || !changed}
className="min-h-11 sm:min-h-9"
>
{t("settings.ai.saveCta")}
</Button>
</div>
</form>
);
}
@@ -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<HTMLElement | null>(null);
useEffect(() => {
if (!focusData) return;
const frame = requestAnimationFrame(() => {
dataRef.current?.focus();
dataRef.current?.scrollIntoView({ block: "start" });
});
return () => cancelAnimationFrame(frame);
}, [focusData]);
return (
<div data-slot="coach-settings-body" className="flex flex-col gap-4">
<section
aria-labelledby="coach-settings-model-heading"
data-slot="coach-settings-model"
className="flex flex-col gap-3"
>
<h3
id="coach-settings-model-heading"
className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wide uppercase"
>
<Cpu className="text-muted-foreground size-3.5" aria-hidden="true" />
{t("insights.coach.frame.modelSection")}
</h3>
<CoachModelPicker />
</section>
<section
ref={dataRef}
id={COACH_SETTINGS_DATA_SECTION_ID}
data-slot="coach-settings-data"
tabIndex={-1}
aria-label={t("insights.coach.sourcesTitle")}
className="border-border focus-visible:ring-ring/50 border-t pt-4 outline-none focus-visible:ring-2"
>
<SourcesRail className="h-auto p-0" />
</section>
<div className="border-border border-t pt-4">
<Button asChild variant="outline" size="sm" className="min-h-11">
<Link href="/settings/ai" data-slot="coach-settings-all">
{t("insights.coach.frame.allSettings")}
</Link>
</Button>
</div>
</div>
);
}
@@ -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: () => <BodyLoading /> },
);
function BodyLoading() {
return (
<div className="flex min-h-24 items-center justify-center">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
</div>
);
}
/**
* 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<HTMLButtonElement>(null);
const gear = (
<Button
ref={gearRef}
type="button"
variant="ghost"
size="icon"
data-slot="coach-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
aria-haspopup="dialog"
aria-expanded={open}
onClick={isPhone ? () => onOpenChange(true) : undefined}
className={cn(
"text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9",
className,
)}
>
<Settings className="size-4" aria-hidden="true" />
</Button>
);
if (isPhone) {
return (
<>
{gear}
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("insights.coach.settingsAriaLabel")}
onCloseAutoFocus={(event) => {
event.preventDefault();
gearRef.current?.focus();
}}
>
<CoachSettingsBody focusData={focusData} />
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={onOpenChange} modal>
<PopoverTrigger asChild>{gear}</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
aria-labelledby="coach-settings-title"
data-slot="coach-settings-popover"
className="flex max-h-[min(36rem,80dvh)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-3 overflow-y-auto overscroll-contain p-4 text-sm"
onOpenAutoFocus={(event) => {
// The body moves focus to its data section once it has loaded.
if (focusData) event.preventDefault();
}}
>
<h2 id="coach-settings-title" className="text-base font-semibold">
{t("insights.coach.settingsAriaLabel")}
</h2>
<CoachSettingsBody focusData={focusData} />
</PopoverContent>
</Popover>
);
}
@@ -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 (
<Button
type="button"
variant="ghost"
size={compact ? "icon" : "icon-lg"}
size="icon-lg"
onClick={() => {
setDraft(title);
setEditing(true);
}}
aria-label={t("insights.coach.rename.action")}
data-slot="coach-conversation-rename"
className={cn("shrink-0", compact && "size-11")}
className="shrink-0"
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
@@ -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",
)}
>
<Input
@@ -0,0 +1,288 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import {
PanelRightClose,
PanelRightOpen,
SquarePen,
Target,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { TopBarActions } from "@/components/layout/top-bar-actions";
import { useCoachPanelOpen } from "@/hooks/use-coach-panel-open";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { HistoryRail } from "./history-rail";
import { useDeleteCoachConversationWithUndo } from "./use-coach";
/**
* The Coach page's conversations panel, on the right of the thread.
*
* From 1280 px it is a column beside the thread that slides open and shut
* (a 200 ms width transition, none under reduced motion). The thread column
* keeps its width either way; only its centring moves. It is open on a first
* visit and remembers the last choice per device (`useCoachPanelOpen`).
* Below 1280 px it is a sheet from the right that opens when asked and
* closes again when a conversation is picked or a new chat starts.
*
* The toggle sits at the trailing edge of the top bar. The panel header
* carries the title, a link to Plans, the settings gear and, in the sheet,
* a close button. The round New chat button sits at the bottom right.
*
* Keyboard: Escape closes the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
* already claimed the key, then focus returns to the toggle. In the sheet
* Radix owns Escape, the focus trap and the return.
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
export interface ConversationsPanelProps {
activeId: string | null;
onSelect: (id: string) => 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<HTMLButtonElement>(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) => (
<div
data-slot="coach-conversations-panel-header"
className="border-border flex h-14 shrink-0 items-center gap-1 border-b pr-2 pl-3"
>
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-sm font-semibold">
{t("insights.coach.historyTitle")}
</SheetTitle>
) : (
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold">
{t("insights.coach.historyTitle")}
</h2>
)}
<Button
asChild
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
>
<Link
href="/coach/plans"
data-slot="coach-panel-plans"
aria-label={t("coach.plans.title")}
title={t("coach.plans.title")}
>
<Target className="size-4" aria-hidden="true" />
</Link>
</Button>
<CoachSettingsOverlay
open={settingsOpen}
onOpenChange={(next) => {
setSettingsOpen(next);
if (!next) setSettingsOnData(false);
}}
focusData={settingsOnData}
/>
{inSheet ? (
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-panel-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 sm:size-9"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
) : null}
</div>
);
const body = (inSheet: boolean) => (
<>
{header(inSheet)}
<HistoryRail
hideHeading
activeId={activeId}
onSelect={(id) => {
onSelect(id);
afterPick();
}}
deletion={deletion}
onDeleteActive={() => onNewChat()}
onUndoDeleteActive={(id) => onSelect(id)}
className="min-h-0 flex-1"
/>
<Button
type="button"
size="icon"
onClick={() => {
onNewChat();
afterPick();
}}
data-slot="coach-panel-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="absolute right-4 bottom-4 size-12 rounded-full shadow-md"
>
<SquarePen className="size-5" aria-hidden="true" />
</Button>
</>
);
return (
<>
<TopBarActions>
<Button
ref={toggleRef}
type="button"
variant="ghost"
size="icon"
onClick={toggle}
aria-controls={COACH_PANEL_ID}
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
data-slot="coach-panel-toggle"
className="text-muted-foreground hover:text-foreground size-11"
>
{expanded ? (
<PanelRightClose className="size-5" aria-hidden="true" />
) : (
<PanelRightOpen className="size-5" aria-hidden="true" />
)}
</Button>
</TopBarActions>
{docked ? (
<aside
id={COACH_PANEL_ID}
aria-label={t("insights.coach.historyTitle")}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
inert={!dockedOpen}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
closeDocked();
}}
className={cn(
// The width animates; the inner column keeps 18rem and is pinned
// to the panel's left edge, so it slides out to the right instead
// of squeezing. The thread column beside it never changes width.
"bg-background relative shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-0",
)}
>
<div className="border-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
</aside>
) : (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
id={COACH_PANEL_ID}
side="right"
showCloseButton={false}
data-slot="coach-conversations-panel"
className="w-[22rem] max-w-[90vw] gap-0 p-0 sm:max-w-[22rem]"
onOpenAutoFocus={(event) => {
// 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();
}
}}
>
<SheetDescription className="sr-only">
{t("insights.coach.frame.panelDescription")}
</SheetDescription>
{body(true)}
</SheetContent>
</Sheet>
)}
</>
);
}
@@ -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 `<aside>`).
*/
className?: string;
/**
* v1.18.1 — suppress the rail's own `<h3>` "Conversations" label. The
* page surface wraps the rail in an `<aside>` that already renders an
* `<h2>` band heading with the same text, so the inline rail must not
* stack a second identical heading. Defaults to `false` (the mobile
* tray + standalone uses keep the heading).
* Suppress the list's own `<h3>` label when the surface already titles it
* (the conversations panel has its own header).
*/
hideHeading?: boolean;
/**
* The delete-with-undo state, owned by a component that outlives this list.
* Omitted, the list keeps its own.
*/
deletion?: ReturnType<typeof useDeleteCoachConversationWithUndo>;
/**
* Called when the conversation being deleted is the open one, before the
* row hides, so the surface can move off it; `onUndo` puts it back.
*/
onDeleteActive?: (id: string) => void;
onUndoDeleteActive?: (id: string) => void;
}
export function HistoryRail({
@@ -64,12 +83,12 @@ export function HistoryRail({
onSelect,
className,
hideHeading = false,
deletion,
onDeleteActive,
onUndoDeleteActive,
}: HistoryRailProps) {
const { t, locale } = useTranslations();
const { t } = useTranslations();
const [filter, setFilter] = useState<string>("");
// v1.30.2 (QoL H1) — the search box now drives a SERVER-side query
// (title-only substring, see the route doc comment), so debounce the
// keystroke draft rather than re-issuing a request per character.
const debouncedFilter = useDebouncedValue(filter, 200);
const {
conversations,
@@ -80,21 +99,21 @@ export function HistoryRail({
isFetchingNextPage,
fetchNextPage,
} = useCoachConversationHistory({ search: debouncedFilter });
const ownDeletion = useDeleteCoachConversationWithUndo();
const { pendingDeleteIds, requestDelete, undoDelete } =
useDeleteCoachConversationWithUndo();
deletion ?? ownDeletion;
// pendingDeleteIds is a client-only hide filter for the just-armed undo
// window — the server-side search already resolved the rest of `visible`.
// `pendingDeleteIds` hides rows inside the undo window; the server-side
// search already resolved everything else.
const visible = useMemo(
() => conversations.filter((c) => !pendingDeleteIds.has(c.id)),
[conversations, pendingDeleteIds],
);
const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
const isSearching = debouncedFilter.trim().length > 0;
// A `useState`-backed callback ref (not a plain `useRef`) so the
// container's assignment itself triggers a re-render — the sentinel
// hook's effect depends on `root` and must re-run once the real DOM
// node exists (it's null during the very first render pass).
// A `useState`-backed callback ref so the sentinel hook re-runs once the
// scroll container exists (null on the very first render).
const [listNode, setListNode] = useState<HTMLDivElement | null>(null);
const sentinelRef = useLoadMoreSentinel({
enabled: hasNextPage && !isFetchingNextPage,
@@ -102,18 +121,17 @@ export function HistoryRail({
root: listNode,
});
// v1.30.1 M5 — a single tap hides the row and schedules the real
// delete; the toast's Undo action cancels it within the grace
// window. Replaces the old arm-then-tap-again confirm, which never
// disarmed. If the user just deleted the active thread, the drawer
// is listening to `onSelect` for navigation; we leave it to the
// parent to clear `activeId` when the conversation disappears.
function handleDeleteRequest(id: string) {
const wasActive = id === activeId;
if (wasActive) onDeleteActive?.(id);
requestDelete(id);
toast.success(t("insights.coach.historyDeleted"), {
action: {
label: t("common.undo"),
onClick: () => undoDelete(id),
onClick: () => {
undoDelete(id);
if (wasActive) onUndoDeleteActive?.(id);
},
},
});
}
@@ -121,19 +139,8 @@ export function HistoryRail({
return (
<div
data-slot="coach-history-rail"
// v1.18.1 (W-COACH-UI C1/C3) — a touch more horizontal padding so
// the list rows clear the rail edge and the search field breathes.
// L5 fix (`.planning/audits/2026-07-18-qa-ui.md`) — `gap-2.5 p-3.5`
// was off the `1/1.5/2/3/4/6/8/10` spacing scale.
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
where the rail is mounted inline (no `SheetTitle` wrapper).
The mobile rail-tray still wraps the rail inside its own
`SheetTitle`, so screen-reader users hear the section twice
when the tray is open — that's intentional Radix behaviour
(the rail still has to stand alone on desktop). */}
{!hideHeading && (
<h3
data-slot="coach-history-rail-heading"
@@ -156,9 +163,8 @@ export function HistoryRail({
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
aria-label={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
// Full-width, comfortable field (matches the standalone
// conversations page) instead of the old thin h-9 strip.
className="h-10 w-full pl-9"
/>
</div>
@@ -166,8 +172,9 @@ export function HistoryRail({
ref={setListNode}
data-slot="coach-history-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1",
// v1.18.7 — same thin/rounded/subtle scrollbar as the thread.
// `pb-20` keeps the last row clear of the panel's floating New
// chat button; it pads this list's own scroll area, not `main`.
"-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto overscroll-contain px-1 pb-20",
COACH_SCROLLBAR,
)}
>
@@ -179,8 +186,7 @@ export function HistoryRail({
{t("common.loading")}
</p>
) : isError && visible.length === 0 ? (
// A load failure must not masquerade as "no conversations yet" —
// offer a retry instead of the empty copy.
// A load failure must not read as "no conversations yet".
<div
data-slot="coach-history-error"
className="text-muted-foreground flex flex-col items-start gap-2 px-2 py-3 text-sm"
@@ -191,7 +197,7 @@ export function HistoryRail({
{t("common.retry")}
</Button>
</div>
) : visible.length === 0 ? (
) : groups.length === 0 ? (
<p
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
@@ -201,77 +207,35 @@ export function HistoryRail({
: t("insights.coach.historyEmpty")}
</p>
) : (
visible.map((c) => {
const isActive = c.id === activeId;
return (
<div
key={c.id}
data-slot="coach-history-item"
data-active={isActive ? "true" : undefined}
className={cn(
"group relative flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"text-sm transition-colors",
isActive
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(c.id)}
className="min-w-0 flex-1 truncate text-left"
data-slot="coach-history-select"
>
<span className="flex min-w-0 items-center gap-1.5 font-medium">
{/* v1.29.x (S7) — a fenced thread runs on the hardened
fenced endpoint over its attached documents; badge it
with a paperclip (+ count when more than one) so it
reads as distinct in the rail. */}
{c.fenced ? (
<span
className="text-primary inline-flex shrink-0 items-center gap-0.5"
aria-label={t("insights.coach.attach.railBadge")}
>
<Paperclip className="size-3.5" aria-hidden="true" />
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
</span>
) : null}
<span className="truncate">{c.title}</span>
</span>
<span className="text-muted-foreground block text-xs">
{formatRelativeTime(c.updatedAt, t, locale)}
</span>
</button>
<ConversationRename id={c.id} title={c.title} compact />
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
reveal: touch surfaces have no hover, so the delete
affordance was invisible on mobile. The button
sits at 44 px always-visible per the WCAG 2.5.5
floor. v1.30.1 M5 — one tap deletes (with an Undo
toast); the old arm/confirm double-tap is gone. */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDeleteRequest(c.id)}
aria-label={t("insights.coach.historyDeleteAria")}
data-slot="coach-history-delete"
className="size-11 shrink-0"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
);
})
groups.map((group) => (
<section
key={group.id}
data-slot="coach-history-group"
data-group={group.id}
aria-label={t(group.labelKey)}
className="flex flex-col gap-1"
>
{/* h3: one step under the panel's h2 title, and a sibling of the
list's own h3 label where that shows (heading order). */}
<h3 className="text-muted-foreground px-2 text-xs font-medium tracking-wide uppercase">
{t(group.labelKey)}
</h3>
<ul className="flex flex-col gap-0.5">
{group.conversations.map((c) => (
<HistoryRow
key={c.id}
conversation={c}
active={c.id === activeId}
onSelect={onSelect}
onDelete={handleDeleteRequest}
/>
))}
</ul>
</section>
))
)}
{/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this
div into the rail's own scrollport pulls the next cursor page.
Empty + aria-hidden — the loading row right below it is the
a11y-visible signal. */}
{/* IntersectionObserver sentinel: scrolling it into the list's own
scrollport pulls the next cursor page. */}
{visible.length > 0 && hasNextPage ? (
<div ref={sentinelRef} aria-hidden="true" className="h-px" />
) : null}
@@ -292,3 +256,133 @@ export function HistoryRail({
</div>
);
}
function HistoryRow({
conversation: c,
active,
onSelect,
onDelete,
}: {
conversation: CoachConversationDTO;
active: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslations();
const [renaming, setRenaming] = useState(false);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
// Set when the menu closes INTO the rename editor, so the menu does not
// hand focus back to its trigger and steal it from the title field.
const openingRenameRef = useRef(false);
function endRename() {
setRenaming(false);
// Back to the row's menu button, where the edit started.
requestAnimationFrame(() => menuTriggerRef.current?.focus());
}
return (
<li
data-slot="coach-history-item"
data-active={active ? "true" : undefined}
className="group/row relative"
>
<button
type="button"
onClick={() => onSelect(c.id)}
aria-current={active ? "true" : undefined}
title={c.title}
data-slot="coach-history-select"
className={cn(
"flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded-lg py-2 pr-11 pl-2 text-left text-sm transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
// The open conversation reads like the open page in the left
// navigation: the same primary wash, so the two rails agree.
active
? "bg-primary/10 text-primary font-medium"
: "text-foreground hover:bg-muted/60",
)}
>
{/* A fenced thread runs on the hardened document endpoint over its
attachments; the paperclip (with a count above one) marks it. */}
{c.fenced ? (
<span
className="text-primary inline-flex shrink-0 items-center gap-0.5"
aria-label={t("insights.coach.attach.railBadge")}
>
<Paperclip className="size-3.5" aria-hidden="true" />
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
</span>
) : null}
<span className="min-w-0 truncate">{c.title}</span>
</button>
{renaming ? null : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={menuTriggerRef}
type="button"
variant="ghost"
size="icon"
aria-label={t("insights.coach.frame.rowActions", {
title: c.title,
})}
data-slot="coach-history-row-menu"
className={cn(
"text-muted-foreground hover:text-foreground absolute top-1/2 right-0 size-11 -translate-y-1/2 sm:right-1 sm:size-9",
// Pointer devices reveal it on hover or focus; touch shows it
// always, since there is no hover to reveal it with.
"[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/row:opacity-100 [@media(hover:hover)]:group-hover/row:opacity-100 [@media(hover:hover)]:data-[state=open]:opacity-100",
)}
>
<MoreHorizontal className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44"
onCloseAutoFocus={(event) => {
if (openingRenameRef.current) {
openingRenameRef.current = false;
event.preventDefault();
}
}}
>
<DropdownMenuItem
data-slot="coach-history-rename"
onSelect={() => {
openingRenameRef.current = true;
setRenaming(true);
}}
>
<Pencil aria-hidden="true" />
{t("insights.coach.frame.rename")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-slot="coach-history-delete"
onSelect={() => onDelete(c.id)}
>
<Trash2 aria-hidden="true" />
{t("insights.coach.frame.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<ConversationRename
id={c.id}
title={c.title}
editing={renaming}
onEditingChange={(next) => {
if (!next) endRename();
}}
/>
</li>
);
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { TopBarActions, TopBarActionsOutlet } from "../top-bar-actions";
describe("top bar actions slot", () => {
it("renders an empty outlet that takes itself out of the row", () => {
const html = renderToStaticMarkup(
<TopBarActionsOutlet className="ml-auto flex empty:hidden" />,
);
expect(html).toBe(
'<div data-slot="top-bar-actions" class="ml-auto flex empty:hidden"></div>',
);
});
it("renders nothing in place: page actions only ever appear in the outlet", () => {
// On the server (and before the top bar has mounted) there is no outlet,
// so the page's actions render nowhere rather than inline in the page.
const html = renderToStaticMarkup(
<div>
<TopBarActions>
<button type="button">Toggle</button>
</TopBarActions>
</div>,
);
expect(html).toBe("<div></div>");
});
});
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/**
* A page-owned slot at the trailing edge of the top bar.
*
* The top bar is shell chrome and knows nothing about pages; a page that
* needs a control up there (the Coach's conversations-panel toggle) renders
* `<TopBarActions>` anywhere in its own tree and the content is portalled
* into `<TopBarActionsOutlet>`. Portalling keeps the control inside the
* page's React tree, so it reads the page's state and context directly and
* no node has to be handed across a context on every render.
*
* The slot empties itself when the page unmounts: the portal goes with the
* component that rendered it, so no other route ever shows a stale control.
*
* The outlet's element is held in a module-level store rather than a context
* provider. The shell mounts exactly one top bar, so there is one outlet, and
* a store keeps the shell's tree untouched.
*/
let outlet: HTMLElement | null = null;
const listeners = new Set<() => void>();
function setOutlet(node: HTMLElement | null) {
if (outlet === node) return;
outlet = node;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Where the top bar paints page actions. Empty unless a page fills it. */
export function TopBarActionsOutlet({ className }: { className?: string }) {
return (
<div ref={setOutlet} data-slot="top-bar-actions" className={className} />
);
}
/** Render `children` into the top bar's action slot. */
export function TopBarActions({ children }: { children: React.ReactNode }) {
const target = useSyncExternalStore(
subscribe,
() => outlet,
() => null,
);
if (!target) return null;
return createPortal(children, target);
}
+7
View File
@@ -15,6 +15,7 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -106,6 +107,12 @@ export function TopBar() {
{/* Desktop: empty spacer (user controls are in sidebar) */}
<div className="hidden md:block" />
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
keeps them at the trailing edge, right before the mobile avatar
menu; `empty:hidden` takes the slot out of the row on every page
that leaves it empty, so the header lays out exactly as before. */}
<TopBarActionsOutlet className="ml-auto flex items-center gap-1 empty:hidden" />
{/* Mobile-only auth section (desktop uses sidebar user section) */}
<div className="flex items-center gap-2 md:hidden">
{isLoading ? (
@@ -54,16 +54,16 @@ beforeEach(() => {
});
describe("Coach links follow the Coach's capability", () => {
it("the sources pointer links to the Coach while it is available", () => {
it("the sources pointer opens the Coach's settings on what it can see", () => {
expect(render(<CoachPrefsSection isAuthenticated />)).toContain(
'href="/coach"',
'href="/coach?settings=data"',
);
});
it("the sources pointer offers no dead link without the Coach", () => {
coachState.available = false;
const html = render(<CoachPrefsSection isAuthenticated />);
expect(html).not.toContain('href="/coach"');
expect(html).not.toContain('href="/coach?settings=data"');
expect(html).not.toContain("coach-prefs-sources-pointer");
});
@@ -223,8 +223,9 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
{/* Data clusters + analysis window live on ONE owner: the
sources rail inside the chat ("What I draw on"). This card
{/* Data clusters + analysis window live on ONE owner: "What I
can see" behind the Coach's settings gear, which
`/coach?settings=data` opens directly. This card
used to render its own copies of both controls; two writable
surfaces for the same persisted fields is exactly the
redundancy the rail was built to end. A pointer replaces
@@ -237,7 +238,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
href="/coach"
href="/coach?settings=data"
className="text-foreground underline underline-offset-2"
>
{t("insights.coach.settingsSourcesPointerLink")}
+10
View File
@@ -88,6 +88,13 @@ export interface ResponsiveSheetProps {
* default, so the width now resolves from this single source.
*/
contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl" | "6xl";
/**
* Where focus goes when the surface closes. Radix returns it to a
* `Dialog.Trigger`, and this primitive has none, so a caller whose opener
* should get focus back (a gear, a menu button) hands it over here: call
* `event.preventDefault()` and focus the opener.
*/
onCloseAutoFocus?: (event: Event) => void;
children: React.ReactNode;
}
@@ -136,6 +143,7 @@ export function ResponsiveSheet({
bodyClassName,
showCloseButton = true,
contentWidth = "md",
onCloseAutoFocus,
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
@@ -144,6 +152,7 @@ export function ResponsiveSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
side="bottom"
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
@@ -246,6 +255,7 @@ export function ResponsiveSheet({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onCloseAutoFocus={onCloseAutoFocus}
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="dialog"
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
COACH_PANEL_STORAGE_KEY,
readCoachPanelPreference,
resolveCoachPanelOpen,
writeCoachPanelPreference,
} from "../use-coach-panel-open";
function memoryStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => void map.set(key, value),
map,
};
}
const throwing = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
};
describe("coach panel open preference", () => {
it("opens the docked panel on a first visit", () => {
const storage = memoryStorage();
expect(readCoachPanelPreference(storage)).toBeNull();
expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
});
it("remembers a closed panel, and an opened one, across reads", () => {
const storage = memoryStorage();
expect(writeCoachPanelPreference(storage, false)).toBe(true);
expect(storage.map.get(COACH_PANEL_STORAGE_KEY)).toBe("false");
expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(
false,
);
writeCoachPanelPreference(storage, true);
expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true);
});
it("treats an unknown stored value as no choice", () => {
const storage = memoryStorage({ [COACH_PANEL_STORAGE_KEY]: "maybe" });
expect(readCoachPanelPreference(storage)).toBeNull();
});
it("falls back to the default when storage throws or is absent", () => {
expect(readCoachPanelPreference(throwing)).toBeNull();
expect(writeCoachPanelPreference(throwing, false)).toBe(false);
expect(readCoachPanelPreference(null)).toBeNull();
expect(writeCoachPanelPreference(undefined, true)).toBe(false);
});
});
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
/**
* The Coach page's conversations panel: whether it sits docked beside the
* thread, and whether the person last left it open there.
*
* Docked means a viewport of at least 1280 px (`xl`). Below that the panel is
* a sheet over the page and opens only when asked, so nothing here applies.
* At `xl` it is open on a first visit and remembers the last choice per
* device after that.
*
* The choice is a convenience, not a record: it lives in `localStorage`, every
* read and write is wrapped because storage can throw (private windows,
* blocked site data), and an unreadable store falls back to the first-visit
* default. Both values come through `useSyncExternalStore`, so the server and
* the hydrating client agree (docked = false, open = true) and the live value
* lands on the first client render after hydration.
*/
export const COACH_PANEL_STORAGE_KEY = "healthlog.coach.panelOpen";
/** The breakpoint at which the panel docks beside the thread. */
export const COACH_PANEL_DOCKED_QUERY = "(min-width: 1280px)";
/** Same-tab change signal; the `storage` event only fires in other tabs. */
const CHANGE_EVENT = "healthlog:coach-panel-open";
/**
* The last choice made in this tab. Read when the store has nothing, so a
* click still sticks for the session where storage refuses every write.
*/
let sessionChoice: boolean | null = null;
/**
* The remembered choice. `null` = nothing stored yet (a first visit), or a
* store that cannot be read; the caller treats both as "open".
*/
export function readCoachPanelPreference(
storage: Pick<Storage, "getItem"> | null | undefined,
): boolean | null {
if (!storage) return null;
try {
const raw = storage.getItem(COACH_PANEL_STORAGE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
} catch {
return null;
}
}
/** Persist the choice. Returns false when the store refused the write. */
export function writeCoachPanelPreference(
storage: Pick<Storage, "setItem"> | null | undefined,
open: boolean,
): boolean {
if (!storage) return false;
try {
storage.setItem(COACH_PANEL_STORAGE_KEY, String(open));
return true;
} catch {
return false;
}
}
/** The open state the docked panel paints: a first visit opens it. */
export function resolveCoachPanelOpen(stored: boolean | null): boolean {
return stored ?? true;
}
function safeLocalStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function subscribePreference(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (event: StorageEvent) => {
if (event.key === null || event.key === COACH_PANEL_STORAGE_KEY) {
callback();
}
};
window.addEventListener("storage", onStorage);
window.addEventListener(CHANGE_EVENT, callback);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(CHANGE_EVENT, callback);
};
}
function subscribeDocked(callback: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mql = window.matchMedia(COACH_PANEL_DOCKED_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function readDocked(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(COACH_PANEL_DOCKED_QUERY).matches;
}
/**
* `docked`: the viewport is wide enough for the panel to sit beside the
* thread. `open`: the remembered docked state (meaningful only when docked).
* `setOpen` persists and notifies every subscriber in this tab.
*/
export function useCoachPanelOpen(): {
docked: boolean;
open: boolean;
setOpen: (open: boolean) => void;
} {
const docked = useSyncExternalStore(subscribeDocked, readDocked, () => false);
const open = useSyncExternalStore(
subscribePreference,
() =>
resolveCoachPanelOpen(
readCoachPanelPreference(safeLocalStorage()) ?? sessionChoice,
),
() => true,
);
const setOpen = useCallback((next: boolean) => {
sessionChoice = next;
writeCoachPanelPreference(safeLocalStorage(), next);
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(CHANGE_EVENT));
}
}, []);
return { docked, open, setOpen };
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { groupConversationsByRecency } from "../coach-conversation-groups";
// The suite runs with TZ=UTC, so local day boundaries are UTC midnights.
const NOW = new Date("2026-10-04T15:00:00.000Z");
function c(id: string, updatedAt: string) {
return { id, updatedAt };
}
describe("groupConversationsByRecency", () => {
it("buckets by local day: today, yesterday, the six days before, earlier", () => {
const groups = groupConversationsByRecency(
[
c("a", "2026-10-04T00:00:00.000Z"), // today, at midnight
c("b", "2026-10-03T23:59:59.000Z"), // yesterday, last second
c("c", "2026-10-03T00:00:00.000Z"), // yesterday, first second
c("d", "2026-09-28T00:00:00.000Z"), // six days before today
c("e", "2026-09-27T23:59:59.000Z"), // one second older: earlier
],
NOW,
);
expect(groups.map((g) => [g.id, g.conversations.map((x) => x.id)])).toEqual(
[
["today", ["a"]],
["yesterday", ["b", "c"]],
["thisWeek", ["d"]],
["earlier", ["e"]],
],
);
});
it("drops empty groups and keeps the caller's order inside a group", () => {
const groups = groupConversationsByRecency(
[c("x", "2026-01-02T00:00:00.000Z"), c("y", "2026-03-02T00:00:00.000Z")],
NOW,
);
expect(groups).toHaveLength(1);
expect(groups[0].id).toBe("earlier");
expect(groups[0].labelKey).toBe("insights.coach.history.groupEarlier");
expect(groups[0].conversations.map((x) => x.id)).toEqual(["x", "y"]);
});
it("returns nothing for an empty list", () => {
expect(groupConversationsByRecency([], NOW)).toEqual([]);
});
});
@@ -0,0 +1,58 @@
/**
* Bucket Coach conversations by how recently they were last touched: Today,
* Yesterday, This week (the six days before yesterday), Earlier.
*
* Shared by the Coach page's conversations panel and the standalone
* conversation list, so both group a thread the same way. Comparisons run
* against LOCAL day boundaries, so "Today" follows the person's own midnight
* rather than UTC's. Empty groups are dropped; order inside a group is the
* caller's (the server already returns `updatedAt desc`).
*/
export type CoachRecencyGroupId =
"today" | "yesterday" | "thisWeek" | "earlier";
export interface CoachRecencyGroup<T> {
id: CoachRecencyGroupId;
labelKey: string;
conversations: T[];
}
const DAY_MS = 24 * 60 * 60 * 1000;
const GROUPS: ReadonlyArray<{ id: CoachRecencyGroupId; labelKey: string }> = [
{ id: "today", labelKey: "insights.coach.history.groupToday" },
{ id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
{ id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
{ id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
];
export function groupConversationsByRecency<T extends { updatedAt: string }>(
conversations: readonly T[],
now: Date = new Date(),
): CoachRecencyGroup<T>[] {
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - DAY_MS;
const startOfWeek = startOfToday - 6 * DAY_MS;
const buckets: Record<CoachRecencyGroupId, T[]> = {
today: [],
yesterday: [],
thisWeek: [],
earlier: [],
};
for (const conversation of conversations) {
const updated = new Date(conversation.updatedAt).getTime();
if (updated >= startOfToday) buckets.today.push(conversation);
else if (updated >= startOfYesterday) buckets.yesterday.push(conversation);
else if (updated >= startOfWeek) buckets.thisWeek.push(conversation);
else buckets.earlier.push(conversation);
}
return GROUPS.map((g) => ({ ...g, conversations: buckets[g.id] })).filter(
(g) => g.conversations.length > 0,
);
}