mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge remote-tracking branch 'origin/feat/coach-frame' into release/v1.40.2
This commit is contained in:
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)", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user