mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -0,0 +1,585 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
/**
|
||||
* The Coach page frame: the conversations panel on the right, its toggle in
|
||||
* the top bar, the settings gear in the panel header, and New chat at the
|
||||
* panel's bottom right.
|
||||
*
|
||||
* Contracts under test, at 1440, 1280 and 390 px:
|
||||
*
|
||||
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
|
||||
* and the reading column (thread and composer) is exactly as wide with
|
||||
* the panel open as with it closed. The choice survives a reload.
|
||||
* 2. Escape inside the docked panel closes it and returns focus to the
|
||||
* toggle.
|
||||
* 3. A row's menu renames by keyboard (Escape cancels without closing the
|
||||
* panel, Enter saves once) and deletes with an Undo that keeps the
|
||||
* DELETE from ever leaving; a delete left alone commits once.
|
||||
* 4. The gear opens the quick settings as a popover; Escape closes it and
|
||||
* focus returns to the gear. `/coach?settings=data` opens it on "What I
|
||||
* can see" and drops the param from the URL.
|
||||
* 5. axe finds nothing on the open panel or the open settings, in the
|
||||
* light and the dark theme.
|
||||
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
|
||||
* right edge; Escape closes it and focus returns to the toggle. The gear
|
||||
* opens a bottom sheet that closes on Escape back to the gear. Nothing
|
||||
* scrolls sideways.
|
||||
*
|
||||
* Every Coach route is stubbed; the provider chain and provider config are
|
||||
* stubbed too, so the model section renders the same on every machine.
|
||||
* Screenshots are attached to the report (no pixel baselines).
|
||||
*/
|
||||
|
||||
const NOW = Date.now();
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function conversations(): Conversation[] {
|
||||
return [
|
||||
{
|
||||
id: "frame-bp",
|
||||
title: "Blood pressure this week",
|
||||
updatedAt: new Date(NOW - HOUR).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "frame-sleep",
|
||||
title: "Sleep since May",
|
||||
updatedAt: new Date(NOW - 26 * HOUR).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "frame-weight",
|
||||
title: "Weight over the summer",
|
||||
updatedAt: new Date(NOW - 10 * 24 * HOUR).toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const ANSWER =
|
||||
"Your blood pressure held a steady band this week. The mornings ran a little higher than the evenings, which is common and nothing that stands out on its own.";
|
||||
|
||||
function fulfilJson(route: Route, data: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data, error: null }),
|
||||
});
|
||||
}
|
||||
|
||||
interface CoachMock {
|
||||
patches: Array<{ id: string; title: string }>;
|
||||
deletes: string[];
|
||||
}
|
||||
|
||||
async function mockCoach(page: Page): Promise<CoachMock> {
|
||||
const state = conversations();
|
||||
const mock: CoachMock = { patches: [], deletes: [] };
|
||||
|
||||
const dto = (c: Conversation) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
createdAt: c.updatedAt,
|
||||
updatedAt: c.updatedAt,
|
||||
messageCount: 2,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
});
|
||||
|
||||
await page.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
|
||||
async (route: Route, request: Request) => {
|
||||
const url = new URL(request.url());
|
||||
const match = url.pathname.match(/^\/api\/insights\/chat\/([^/]+)$/);
|
||||
if (match) {
|
||||
const id = decodeURIComponent(match[1]);
|
||||
const found = state.find((c) => c.id === id);
|
||||
if (request.method() === "PATCH") {
|
||||
const { title } = request.postDataJSON() as { title: string };
|
||||
mock.patches.push({ id, title });
|
||||
if (found) found.title = title;
|
||||
return fulfilJson(route, { id, title });
|
||||
}
|
||||
if (request.method() === "DELETE") {
|
||||
mock.deletes.push(id);
|
||||
const idx = state.findIndex((c) => c.id === id);
|
||||
if (idx >= 0) state.splice(idx, 1);
|
||||
return fulfilJson(route, { deleted: true });
|
||||
}
|
||||
if (!found) return fulfilJson(route, null, 404);
|
||||
return fulfilJson(route, {
|
||||
...dto(found),
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: [
|
||||
{
|
||||
id: `${id}-u`,
|
||||
role: "user",
|
||||
content: `Tell me about ${found.title.toLowerCase()}.`,
|
||||
createdAt: found.updatedAt,
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: `${id}-a`,
|
||||
role: "assistant",
|
||||
content: ANSWER,
|
||||
createdAt: found.updatedAt,
|
||||
metricSource: null,
|
||||
providerType: "mock",
|
||||
promptVersion: "e2e",
|
||||
tokensUsed: 640,
|
||||
model: "mock",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith("/api/insights/chat/")) {
|
||||
return fulfilJson(route, { results: [] });
|
||||
}
|
||||
const q = (url.searchParams.get("q") ?? "").toLowerCase();
|
||||
return fulfilJson(route, {
|
||||
conversations: state
|
||||
.filter((c) => c.title.toLowerCase().includes(q))
|
||||
.map(dto),
|
||||
nextCursor: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
||||
fulfilJson(route, { nudgedAt: null, unread: false }),
|
||||
);
|
||||
await page.route("**/api/coach/about-me/questions*", (route) =>
|
||||
fulfilJson(route, { questions: [] }),
|
||||
);
|
||||
await page.route("**/api/insights/coach/seeded-question*", (route) =>
|
||||
fulfilJson(route, { signal: null }),
|
||||
);
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? fulfilJson(route, {
|
||||
activeProvider: "local",
|
||||
cachedActiveProvider: "local",
|
||||
configuredChain: [
|
||||
{ providerType: "local", enabled: true, available: true },
|
||||
{ providerType: "admin-openai", enabled: true, available: true },
|
||||
],
|
||||
})
|
||||
: fulfilJson(route, { saved: true }),
|
||||
);
|
||||
await page.route("**/api/user/ai-provider", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? fulfilJson(route, {
|
||||
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: null,
|
||||
compatReasoningEffort: null,
|
||||
})
|
||||
: fulfilJson(route, { updated: true }),
|
||||
);
|
||||
return mock;
|
||||
}
|
||||
|
||||
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
|
||||
const panel = (page: Page) =>
|
||||
page.locator('[data-slot="coach-conversations-panel"]');
|
||||
|
||||
/** Width of the reading column: the thread's capped inner column. */
|
||||
async function columnWidths(page: Page) {
|
||||
const thread = page.locator('[data-slot="coach-message-thread"] > *').first();
|
||||
const composer = page.locator('[data-slot="coach-page-composer"] > div');
|
||||
const t = await thread.boundingBox();
|
||||
const c = await composer.boundingBox();
|
||||
expect(t, "thread column").not.toBeNull();
|
||||
expect(c, "composer column").not.toBeNull();
|
||||
return { thread: t!.width, composer: c!.width, threadX: t!.x };
|
||||
}
|
||||
|
||||
async function expectNoSidewaysScroll(page: Page) {
|
||||
const overflow = await page.evaluate(() => {
|
||||
const doc = document.scrollingElement!;
|
||||
const main = document.getElementById("main-content")!;
|
||||
return {
|
||||
doc: doc.scrollWidth - doc.clientWidth,
|
||||
main: main.scrollWidth - main.clientWidth,
|
||||
};
|
||||
});
|
||||
expect(overflow.doc).toBeLessThanOrEqual(0);
|
||||
expect(overflow.main).toBeLessThanOrEqual(0);
|
||||
}
|
||||
|
||||
async function shot(page: Page, testInfo: TestInfo, name: string) {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
const dir = process.env.COACH_SHOTS_DIR;
|
||||
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
|
||||
}
|
||||
|
||||
async function openCoach(page: Page, path = "/coach?c=frame-bp") {
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function waitForWidth(locator: Locator, width: number) {
|
||||
await expect
|
||||
.poll(async () => Math.round((await locator.boundingBox())?.width ?? -1))
|
||||
.toBe(width);
|
||||
}
|
||||
|
||||
test.describe("Coach page frame", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"each case sets its own viewport; the desktop project runs them all",
|
||||
);
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
});
|
||||
|
||||
for (const width of [1440, 1280]) {
|
||||
test(`${width}: the panel docks, toggles, persists, and the reading column never changes width`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
|
||||
// First visit: open.
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await waitForWidth(panel(page), 288);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(toggle(page)).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"coach-conversations-panel",
|
||||
);
|
||||
const open = await columnWidths(page);
|
||||
expect(open.thread).toBeCloseTo(672, 0);
|
||||
expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
|
||||
// The panel sits right of the column, never over it.
|
||||
const aside = (await panel(page).boundingBox())!;
|
||||
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-open`);
|
||||
|
||||
await toggle(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await waitForWidth(panel(page), 0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
const closed = await columnWidths(page);
|
||||
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
|
||||
// Closed, nothing inside the panel is reachable.
|
||||
await expect(
|
||||
panel(page).locator('[data-slot="coach-history-search"]'),
|
||||
).not.toBeInViewport();
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, `coach-frame-${width}-closed`);
|
||||
|
||||
// Remembered across a reload.
|
||||
await openCoach(page);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
|
||||
// Open again, then Escape from inside closes it, focus to the toggle.
|
||||
await toggle(page).click();
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await panel(page).locator('[data-slot="coach-history-search"]').focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "closed");
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
});
|
||||
}
|
||||
|
||||
test("1440: picking a row opens it and New chat clears it, both in the URL", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
|
||||
const rows = panel(page).locator('[data-slot="coach-history-select"]');
|
||||
await expect(rows).toHaveCount(3);
|
||||
await expect(panel(page).locator('[data-group="today"]')).toContainText(
|
||||
"Blood pressure this week",
|
||||
);
|
||||
await expect(panel(page).locator('[data-group="yesterday"]')).toContainText(
|
||||
"Sleep since May",
|
||||
);
|
||||
await expect(rows.nth(0)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
await rows.nth(1).click();
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
|
||||
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
|
||||
const fabBox = (await fab.boundingBox())!;
|
||||
const asideBox = (await panel(page).boundingBox())!;
|
||||
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
|
||||
16,
|
||||
0,
|
||||
);
|
||||
await fab.click();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-input-textarea"]'),
|
||||
).toBeVisible();
|
||||
await expect(panel(page).locator('[aria-current="true"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("1440: rename by keyboard and delete with undo from the row menu", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const mock = await mockCoach(page);
|
||||
await openCoach(page);
|
||||
|
||||
const row = panel(page)
|
||||
.locator('[data-slot="coach-history-item"]')
|
||||
.filter({ hasText: "Sleep since May" });
|
||||
const menu = row.locator('[data-slot="coach-history-row-menu"]');
|
||||
await expect(menu).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Actions for Sleep since May",
|
||||
);
|
||||
|
||||
// Keyboard: focus the menu button, open it, pick Rename.
|
||||
await menu.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click();
|
||||
const input = row.locator('[data-slot="coach-conversation-rename-input"]');
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Discard this");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(row).toContainText("Sleep since May");
|
||||
await expect(menu).toBeFocused();
|
||||
expect(mock.patches).toHaveLength(0);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click();
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Sleep since spring");
|
||||
await page.keyboard.press("Enter");
|
||||
const renamed = panel(page)
|
||||
.locator('[data-slot="coach-history-item"]')
|
||||
.filter({ hasText: "Sleep since spring" });
|
||||
await expect(renamed).toHaveCount(1);
|
||||
await expect(input).toHaveCount(0);
|
||||
expect(mock.patches).toEqual([
|
||||
{ id: "frame-sleep", title: "Sleep since spring" },
|
||||
]);
|
||||
|
||||
// Delete, then Undo: the row returns and no DELETE leaves.
|
||||
await renamed.hover();
|
||||
await renamed.locator('[data-slot="coach-history-row-menu"]').click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await expect(renamed).toHaveCount(0);
|
||||
await shot(page, testInfo, "coach-frame-1440-delete-toast");
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
await expect(renamed).toHaveCount(1);
|
||||
await page.waitForTimeout(6_500);
|
||||
expect(mock.deletes).toEqual([]);
|
||||
|
||||
// Delete left alone commits once, after the undo window.
|
||||
await renamed.hover();
|
||||
await renamed.locator('[data-slot="coach-history-row-menu"]').click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await expect(renamed).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => mock.deletes, { timeout: 10_000 })
|
||||
.toEqual(["frame-sleep"]);
|
||||
});
|
||||
|
||||
test("1440: the gear opens the quick settings as a popover; Escape returns to the gear", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
|
||||
const gear = panel(page).locator('[data-slot="coach-settings"]');
|
||||
await gear.click();
|
||||
const popover = page.locator('[data-slot="coach-settings-popover"]');
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(popover).toHaveAttribute("role", "dialog");
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-quick-provider"]'),
|
||||
).toHaveValue("local");
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-quick-model"]'),
|
||||
).toHaveValue("qwen2.5");
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-sources-window"]'),
|
||||
).toBeVisible();
|
||||
// The popover stays inside the viewport.
|
||||
const box = (await popover.boundingBox())!;
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(1440);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(900);
|
||||
await shot(page, testInfo, "coach-frame-1440-settings");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
await expect(gear).toBeFocused();
|
||||
// Escape on the popover does not also close the panel.
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
});
|
||||
|
||||
test("1440: /coach?settings=data opens the settings on what the Coach sees", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockCoach(page);
|
||||
// A closed panel is revealed for the deep link.
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("healthlog.coach.panelOpen", "false");
|
||||
});
|
||||
await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const popover = page.locator('[data-slot="coach-settings-popover"]');
|
||||
await expect(popover).toBeVisible({ timeout: 15_000 });
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-settings-data"]'),
|
||||
).toBeFocused();
|
||||
await expect(page).toHaveURL(/\/coach$/);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`1440 ${theme}: axe finds nothing on the panel or the quick settings`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
|
||||
await page.addInitScript((value: string) => {
|
||||
window.localStorage.setItem("healthlog-theme", value);
|
||||
}, theme);
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
await expect(page.locator("html")).toHaveClass(
|
||||
new RegExp(`(^|\\s)${theme}(\\s|$)`),
|
||||
);
|
||||
await expect(panel(page)).toHaveAttribute("data-state", "open");
|
||||
|
||||
const scan = async (selector: string, label: string) => {
|
||||
const result = await new AxeBuilder({ page })
|
||||
.include(selector)
|
||||
// The data-area switches belong to the sources rail, which this
|
||||
// frame embeds unchanged; a switched-off row dims its label below
|
||||
// AA (`opacity-60` over muted text). That is the rail's own defect,
|
||||
// left for the rail's rework with the lookback limit.
|
||||
.exclude('[data-slot="coach-sources-list"]')
|
||||
.withTags([
|
||||
"wcag2a",
|
||||
"wcag2aa",
|
||||
"wcag21a",
|
||||
"wcag21aa",
|
||||
"best-practice",
|
||||
])
|
||||
.analyze();
|
||||
expect(
|
||||
result.violations.map((v) => ({
|
||||
id: v.id,
|
||||
nodes: v.nodes.map((n) => n.target.join(" ")),
|
||||
})),
|
||||
`${label} accessibility violations`,
|
||||
).toEqual([]);
|
||||
};
|
||||
|
||||
await scan('[data-slot="coach-conversations-panel"]', "panel");
|
||||
await shot(page, testInfo, `coach-frame-1440-${theme}-panel`);
|
||||
|
||||
await panel(page).locator('[data-slot="coach-settings"]').click();
|
||||
const popover = page.locator('[data-slot="coach-settings-popover"]');
|
||||
await expect(
|
||||
popover.locator('[data-slot="coach-quick-provider"]'),
|
||||
).toBeVisible();
|
||||
await scan('[data-slot="coach-settings-popover"]', "settings");
|
||||
await shot(page, testInfo, `coach-frame-1440-${theme}-settings`);
|
||||
});
|
||||
}
|
||||
|
||||
test("390: the panel is a sheet, the gear a bottom sheet, and nothing scrolls sideways", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await mockCoach(page);
|
||||
await openCoach(page);
|
||||
|
||||
// Below 1280 px the panel waits to be asked.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
|
||||
await expectNoSidewaysScroll(page);
|
||||
await shot(page, testInfo, "coach-frame-390-thread");
|
||||
|
||||
await toggle(page).click();
|
||||
const sheet = page.getByRole("dialog", { name: "Conversations" });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
|
||||
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
|
||||
const fabBox = (await fab.boundingBox())!;
|
||||
expect(390 - (fabBox.x + fabBox.width)).toBeCloseTo(16, 0);
|
||||
expect(fabBox.width).toBeGreaterThanOrEqual(44);
|
||||
await shot(page, testInfo, "coach-frame-390-panel");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(toggle(page)).toBeFocused();
|
||||
|
||||
// The gear in the sheet opens a bottom sheet over it.
|
||||
await toggle(page).click();
|
||||
await expect(sheet).toBeVisible();
|
||||
const gear = sheet.locator('[data-slot="coach-settings"]');
|
||||
await gear.click();
|
||||
const settings = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(settings).toHaveAttribute("data-variant", "sheet");
|
||||
await expect(
|
||||
settings.locator('[data-slot="coach-quick-provider"]'),
|
||||
).toBeVisible();
|
||||
const box = (await settings.boundingBox())!;
|
||||
expect(Math.round(box.y + box.height)).toBe(844);
|
||||
await shot(page, testInfo, "coach-frame-390-settings");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(settings).toHaveCount(0);
|
||||
await expect(gear).toBeFocused();
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// Picking a conversation closes the sheet and opens the thread.
|
||||
await sheet
|
||||
.locator('[data-slot="coach-history-select"]')
|
||||
.filter({ hasText: "Weight over the summer" })
|
||||
.click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/coach\?c=frame-weight$/);
|
||||
await expectNoSidewaysScroll(page);
|
||||
});
|
||||
});
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Ausführlich",
|
||||
"settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
|
||||
"settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).",
|
||||
"settingsSourcesPointerLink": "Coach öffnen",
|
||||
"settingsSourcesPointer": "Welche Daten der Coach lesen darf und wie weit er zurückblickt, stellst du im Coach selbst ein, hinter dem Zahnrad („Worauf ich zugreife“).",
|
||||
"settingsSourcesPointerLink": "Coach-Einstellungen öffnen",
|
||||
"settingsExcludeLabel": "Metriken ausschließen",
|
||||
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
|
||||
"settingsContextLabel": "Kontext mitschicken",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.",
|
||||
"errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.",
|
||||
"errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.",
|
||||
"errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten."
|
||||
"errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.",
|
||||
"frame": {
|
||||
"showPanel": "Unterhaltungen einblenden",
|
||||
"hidePanel": "Unterhaltungen ausblenden",
|
||||
"panelDescription": "Deine Unterhaltungen mit dem Coach, neueste zuerst.",
|
||||
"rowActions": "Aktionen für {title}",
|
||||
"rename": "Umbenennen",
|
||||
"delete": "Löschen",
|
||||
"modelSection": "Modell",
|
||||
"provider": "Antwortet mit",
|
||||
"modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.",
|
||||
"noProvider": "Kein Anbieter verfügbar",
|
||||
"saving": "Wird gespeichert…",
|
||||
"saveFailed": "Die Änderung ließ sich nicht speichern.",
|
||||
"allSettings": "Alle KI-Einstellungen"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "Default",
|
||||
"settingsVerbosityDetailed": "Detailed",
|
||||
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
|
||||
"settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
|
||||
"settingsSourcesPointerLink": "Open the coach",
|
||||
"settingsSourcesPointer": "Which data the Coach may read and how far back it looks are set in the Coach itself, behind the gear (\"What I can see\").",
|
||||
"settingsSourcesPointerLink": "Open Coach settings",
|
||||
"settingsExcludeLabel": "Exclude metrics",
|
||||
"settingsExcludeHint": "The Coach will not see data for selected metrics.",
|
||||
"settingsContextLabel": "Include context",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "The Coach is not available inside a record someone shared with you.",
|
||||
"errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.",
|
||||
"errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.",
|
||||
"errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules."
|
||||
"errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.",
|
||||
"frame": {
|
||||
"showPanel": "Show conversations",
|
||||
"hidePanel": "Hide conversations",
|
||||
"panelDescription": "Your conversations with the Coach, newest first.",
|
||||
"rowActions": "Actions for {title}",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"modelSection": "Model",
|
||||
"provider": "Answers with",
|
||||
"modelManaged": "The model for this provider is set in AI settings.",
|
||||
"noProvider": "No provider available",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "That change could not be saved.",
|
||||
"allSettings": "All AI settings"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "Estándar",
|
||||
"settingsVerbosityDetailed": "Detallado",
|
||||
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
|
||||
"settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
|
||||
"settingsSourcesPointerLink": "Abrir el coach",
|
||||
"settingsSourcesPointer": "Qué datos puede leer el coach y hasta cuándo mira atrás se ajusta en el propio coach, tras el engranaje («A qué accedo»).",
|
||||
"settingsSourcesPointerLink": "Abrir ajustes del coach",
|
||||
"settingsExcludeLabel": "Excluir métricas",
|
||||
"settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.",
|
||||
"settingsContextLabel": "Enviar contexto",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.",
|
||||
"errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.",
|
||||
"errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.",
|
||||
"errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos."
|
||||
"errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.",
|
||||
"frame": {
|
||||
"showPanel": "Mostrar conversaciones",
|
||||
"hidePanel": "Ocultar conversaciones",
|
||||
"panelDescription": "Tus conversaciones con el coach, las más recientes primero.",
|
||||
"rowActions": "Acciones para {title}",
|
||||
"rename": "Renombrar",
|
||||
"delete": "Eliminar",
|
||||
"modelSection": "Modelo",
|
||||
"provider": "Responde con",
|
||||
"modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.",
|
||||
"noProvider": "Ningún proveedor disponible",
|
||||
"saving": "Guardando…",
|
||||
"saveFailed": "No se pudo guardar el cambio.",
|
||||
"allSettings": "Todos los ajustes de IA"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Détaillé",
|
||||
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
|
||||
"settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
|
||||
"settingsSourcesPointerLink": "Ouvrir le coach",
|
||||
"settingsSourcesPointer": "Les données que le coach peut lire et jusqu’où il remonte se règlent dans le coach lui-même, derrière la roue dentée (« Ce à quoi j’accède »).",
|
||||
"settingsSourcesPointerLink": "Ouvrir les paramètres du coach",
|
||||
"settingsExcludeLabel": "Exclure des métriques",
|
||||
"settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.",
|
||||
"settingsContextLabel": "Envoyer le contexte",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.",
|
||||
"errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.",
|
||||
"errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.",
|
||||
"errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules."
|
||||
"errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.",
|
||||
"frame": {
|
||||
"showPanel": "Afficher les conversations",
|
||||
"hidePanel": "Masquer les conversations",
|
||||
"panelDescription": "Tes conversations avec le coach, les plus récentes d’abord.",
|
||||
"rowActions": "Actions pour {title}",
|
||||
"rename": "Renommer",
|
||||
"delete": "Supprimer",
|
||||
"modelSection": "Modèle",
|
||||
"provider": "Répond avec",
|
||||
"modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.",
|
||||
"noProvider": "Aucun fournisseur disponible",
|
||||
"saving": "Enregistrement…",
|
||||
"saveFailed": "Impossible d’enregistrer cette modification.",
|
||||
"allSettings": "Tous les paramètres IA"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "Standard",
|
||||
"settingsVerbosityDetailed": "Dettagliata",
|
||||
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
|
||||
"settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
|
||||
"settingsSourcesPointerLink": "Apri il coach",
|
||||
"settingsSourcesPointer": "Quali dati il coach può leggere e fin dove guarda indietro si imposta nel coach stesso, dietro l’ingranaggio («A cosa accedo»).",
|
||||
"settingsSourcesPointerLink": "Apri le impostazioni del coach",
|
||||
"settingsExcludeLabel": "Escludi metriche",
|
||||
"settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.",
|
||||
"settingsContextLabel": "Invia contesto",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.",
|
||||
"errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.",
|
||||
"errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.",
|
||||
"errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli."
|
||||
"errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.",
|
||||
"frame": {
|
||||
"showPanel": "Mostra conversazioni",
|
||||
"hidePanel": "Nascondi conversazioni",
|
||||
"panelDescription": "Le tue conversazioni con il coach, dalla più recente.",
|
||||
"rowActions": "Azioni per {title}",
|
||||
"rename": "Rinomina",
|
||||
"delete": "Elimina",
|
||||
"modelSection": "Modello",
|
||||
"provider": "Risponde con",
|
||||
"modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.",
|
||||
"noProvider": "Nessun fornitore disponibile",
|
||||
"saving": "Salvataggio…",
|
||||
"saveFailed": "Impossibile salvare la modifica.",
|
||||
"allSettings": "Tutte le impostazioni IA"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "기본",
|
||||
"settingsVerbosityDetailed": "자세히",
|
||||
"settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요 — 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.",
|
||||
"settingsSourcesPointer": "코치가 참고할 데이터 묶음과 기본 분석 기간은 코치 대화의 '내가 참고하는 것' 레일에서 바로 관리해요.",
|
||||
"settingsSourcesPointerLink": "코치 열기",
|
||||
"settingsSourcesPointer": "코치가 읽을 수 있는 데이터와 되돌아볼 기간은 코치 안의 톱니바퀴('내가 볼 수 있는 것')에서 정해요.",
|
||||
"settingsSourcesPointerLink": "코치 설정 열기",
|
||||
"settingsExcludeLabel": "제외할 지표",
|
||||
"settingsExcludeHint": "선택한 지표의 데이터는 코치가 보지 않아요.",
|
||||
"settingsContextLabel": "포함할 맥락",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.",
|
||||
"errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.",
|
||||
"errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다."
|
||||
"errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.",
|
||||
"frame": {
|
||||
"showPanel": "대화 목록 보기",
|
||||
"hidePanel": "대화 목록 숨기기",
|
||||
"panelDescription": "코치와 나눈 대화, 최신순이에요.",
|
||||
"rowActions": "{title} 작업",
|
||||
"rename": "이름 바꾸기",
|
||||
"delete": "삭제",
|
||||
"modelSection": "모델",
|
||||
"provider": "응답 제공자",
|
||||
"modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
|
||||
"noProvider": "사용할 수 있는 제공자가 없어요",
|
||||
"saving": "저장 중…",
|
||||
"saveFailed": "변경 사항을 저장하지 못했어요.",
|
||||
"allSettings": "모든 AI 설정"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "방금",
|
||||
"relativeMinutesAgoOne": "{count}분 전",
|
||||
|
||||
+18
-3
@@ -2943,8 +2943,8 @@
|
||||
"settingsVerbosityDefault": "Standardowa",
|
||||
"settingsVerbosityDetailed": "Szczegółowa",
|
||||
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
|
||||
"settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
|
||||
"settingsSourcesPointerLink": "Otwórz coacha",
|
||||
"settingsSourcesPointer": "To, jakie dane coach może czytać i jak daleko wstecz patrzy, ustawisz w samym coachu, pod kołem zębatym („Do czego mam dostęp”).",
|
||||
"settingsSourcesPointerLink": "Otwórz ustawienia coacha",
|
||||
"settingsExcludeLabel": "Wyklucz metryki",
|
||||
"settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.",
|
||||
"settingsContextLabel": "Wysyłaj kontekst",
|
||||
@@ -3113,7 +3113,22 @@
|
||||
"errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.",
|
||||
"errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.",
|
||||
"errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.",
|
||||
"errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły."
|
||||
"errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.",
|
||||
"frame": {
|
||||
"showPanel": "Pokaż rozmowy",
|
||||
"hidePanel": "Ukryj rozmowy",
|
||||
"panelDescription": "Twoje rozmowy z coachem, od najnowszej.",
|
||||
"rowActions": "Działania dla {title}",
|
||||
"rename": "Zmień nazwę",
|
||||
"delete": "Usuń",
|
||||
"modelSection": "Model",
|
||||
"provider": "Odpowiada przez",
|
||||
"modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.",
|
||||
"noProvider": "Brak dostępnego dostawcy",
|
||||
"saving": "Zapisywanie…",
|
||||
"saveFailed": "Nie udało się zapisać zmiany.",
|
||||
"allSettings": "Wszystkie ustawienia AI"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
|
||||
@@ -63,18 +63,26 @@ describe("Coach streaming lifecycle", () => {
|
||||
"components/insights/coach-panel/coach-conversation.tsx",
|
||||
);
|
||||
|
||||
// Isolate the HistoryRail onSelect callback body.
|
||||
// One switch handler, shared by the drawer's history tray and the page's
|
||||
// conversations panel, so neither surface can skip the reset.
|
||||
const match = source.match(
|
||||
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
|
||||
/function selectConversation\(id: string\) \{[\s\S]*?\n {2}\}/,
|
||||
);
|
||||
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
|
||||
const onSelectBody = match![0];
|
||||
|
||||
expect(match, "selectConversation handler not found").not.toBeNull();
|
||||
expect(
|
||||
onSelectBody.includes("send.reset()"),
|
||||
match![0].includes("send.reset()"),
|
||||
"selecting another conversation must call send.reset() so the previous " +
|
||||
"thread's trailing stream state does not bleed into the new thread",
|
||||
).toBe(true);
|
||||
|
||||
const selectSites = source.match(/onSelect=\{([^}]*)\}/g) ?? [];
|
||||
expect(selectSites.length).toBeGreaterThanOrEqual(2);
|
||||
for (const site of selectSites) {
|
||||
expect(
|
||||
site,
|
||||
"every conversation picker routes through selectConversation",
|
||||
).toBe("onSelect={selectConversation}");
|
||||
}
|
||||
});
|
||||
|
||||
it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => {
|
||||
|
||||
@@ -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