Merge remote-tracking branch 'origin/feat/coach-frame' into release/v1.40.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 07:16:09 +02:00
commit 57a889ef3c
34 files changed
+2659 -428

No files matched your search

@@ -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,
);
}