diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index d5c2a04cb..8595436c9 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -4,10 +4,15 @@ Users save reference texts to their Library. The Library belongs to the Current ## Sub-features -- `lib-add` adds a source with a title and content. It appears with its toggle on, and the banner `Grounding Active` shows. +- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved to the Library`; its toggle is on, and the banner `Grounding Active` shows. - `lib-toggle` pauses a source through its switch, which keeps it out of prompts. +- `lib-offline` offline, the switch does not move and a toast says `You are offline. Reconnect and try again.` A write that fails online puts the switch back with a toast. +- `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet. Text typed while a save runs stays an edit and goes out with the next `Save`. +- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's Library.` before any fetch; research refreshes the existing row instead of adding one. +- `lib-layout` on a phone the row's controls take their own line, so a long title gets the row's width and two lines. - `lib-ground` includes only active sources' text in the `system` of later AI calls (Generate, Brainstorm, and others). - `lib-delete` deletes a source. It stays gone after a reload and in the `sources` table. +- `lib-counts` the Current Debate summary on `/app` shows `<total> sources (<active> active)`, and Ask's `Using <n> sources` counts the same active sources. - `lib-import` imports a web page by URL through the `import-source` Edge Function, which fetches the page itself (no Exa), so it works in both modes when the VPS has outbound internet. - `lib-erase` has Settings → `Delete All My Data` remove this account's sources and nobody else's. @@ -24,8 +29,12 @@ Preconditions: - **Add.** Click the button named `Add Source`. Fill the textbox named `Title` (exact), fill the textbox named `Content` (exact) with a unique marker, and click `Save to Library`. Leaving either empty shows an inline error under the field and moves focus to it. The switch named `Toggle <title>` is checked. `sources` has a row with `is_active: true` and the debate's `debate_id`. - **Toggle.** Click the switch named `Toggle <title>`. It becomes not checked. +- **Offline toggle.** `page.context().setOffline(true)`, click the switch: the offline toast shows, the switch stays checked, and the row's `is_active` is unchanged. Still offline, open the source, change its `Content` and click `Save`: the toast `Could not save this source.` shows and the typed text stays in the sheet. Set offline back to false and click `Save` again: `Source updated` shows and the row has the new content with `is_active` unchanged. +- **Duplicate import.** Insert a URL source through `admin`, then import the same page with `?utm_source=x#top`: the duplicate warning shows and no `import-source` request is made. +- **Phone layout.** At a 360px viewport the title button is wider than 200px and the page has no sideways scroll. - **Ground.** Go to `/app/brainstorm`; the motion is prefilled from the debate. Run it (see [ai-tools](./ai-tools.md)). `providerCall("brainstorm").system` contains the active marker and does not contain the paused marker. - **Delete.** Click the button named `Delete <title>` (use `exact`). It deletes at once, with no confirmation, and a toast `Deleted <title>` offers `Undo`, which saves the source again. The switch count drops to 0, and stays 0 after `page.reload()`. `sources` no longer has the title. +- **Counts.** Insert 7 URL sources through `admin`, 5 with `is_active: true`. `/app` test id `home-current-debate` contains `7 sources (5 active)`; on `/app/ask` test id `debate-context-control` contains `Using 5 sources`. - **Import.** In the add panel, fill `Import from URL` and click `Import URL`. The `Import preview` panel appears, then `Save imported source` stores a row whose `origin` is the URL. - **Erase.** On Settings, click `Delete All My Data`. In the dialog, fill `Type your email to confirm` with `user.email` and click the dialog's `Delete All My Data`. The text `Data Deleted` appears, and `sources` for the user is `[]`. diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 484933dd3..05395aca5 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -9,6 +9,7 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a - `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal. - `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution. - `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up. +- `cross-history` after leaving the page, `/app/history?tab=crossfire` lists the round (test id `history-crossfire-row`). `Open Crossfire round: <resolution>` shows test id `saved-crossfire` with the debrief (`saved-crossfire-debrief`) and transcript (`saved-crossfire-transcript`); `Delete Crossfire round: <resolution>` asks first and removes the row. - `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`. - `cross-end-early` ending a round before any exchange returns to setup with no error. - `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests are fewer than the round needs. diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index a3f14c812..35b0cb361 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -22,6 +22,9 @@ test("lib-add, lib-toggle, lib-ground, lib-delete: only active sources ground AI await addSource(page, "Paused study", "PAUSED-SOURCE-MARKER congestion did not change."); await page.getByRole("switch", { name: "Toggle Paused study" }).click(); await expect(page.getByRole("switch", { name: "Toggle Paused study" })).not.toBeChecked(); + // The switch moves before its write lands; leaving the page first could cancel the write. + await expect.poll(async () => (await admin.from("sources").select("is_active").eq("user_id", await user.userId()).eq("title", "Paused study")).data) + .toEqual([{ is_active: false }]); await evidence.step("Added two sources and paused one"); await evidence.shot("01-library"); @@ -83,3 +86,125 @@ test("lib-import: a web page imports into the Library", async ({ signedIn: page, const { data } = await admin.from("sources").select("title,origin").eq("user_id", await user.userId()); await evidence.json("02-imported-row", data); }); + +test("lib-offline: offline, the switch does not move and says the change was not saved", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/library"); + await addSource(page, "Offline study", "Fares and ridership."); + await expect(page.getByText("Saved Offline study to the Library")).toBeVisible(); + await page.context().setOffline(true); + await page.getByRole("switch", { name: "Toggle Offline study" }).click(); + await expect(page.getByText("You are offline. Reconnect and try again.")).toBeVisible(); + await expect(page.getByRole("switch", { name: "Toggle Offline study" })).toBeChecked(); + await evidence.shot("01-offline-toggle"); + // A save that fails keeps what the student typed, so they can retry. + await page.getByRole("button", { name: "Offline study", exact: true }).click(); + const sheet = page.getByRole("dialog"); + await sheet.getByRole("textbox", { name: "Content" }).fill("Fares, ridership and a rewritten note."); + await sheet.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Could not save this source.")).toBeVisible(); + await expect(sheet.getByRole("textbox", { name: "Content" })).toHaveValue("Fares, ridership and a rewritten note."); + await evidence.shot("02-offline-edit-kept"); + await page.context().setOffline(false); + const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Offline study")).data; + expect(await row()).toEqual([{ content: "Fares and ridership.", is_active: true }]); + // Back online, the kept text saves on retry and the switch is left as it was. + await sheet.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Source updated")).toBeVisible(); + await expect.poll(row).toEqual([{ content: "Fares, ridership and a rewritten note.", is_active: true }]); + await evidence.json("03-row-after-retry", await row()); +}); + +test("lib-edit-while-switching: the editor follows a switch that rolls back, and Save sends only what was edited", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/library"); + await addSource(page, "Race study", "Original text."); + // The switch write hangs, then fails, while the editor is open. + const failSwitch = async (route: import("@playwright/test").Route) => { + if (route.request().method() === "PATCH" && (route.request().postData() ?? "").includes("is_active")) { + await new Promise((resolve) => setTimeout(resolve, 1_500)); + await route.abort("failed"); + return; + } + await route.continue(); + }; + await page.route("**/rest/v1/sources?**", failSwitch); + await page.getByRole("switch", { name: "Toggle Race study" }).click(); + await page.getByRole("button", { name: "Race study", exact: true }).click(); + const sheet = page.getByRole("dialog"); + // The editor opened while the write was still running, so it shows the switch off. + await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).not.toBeChecked(); + await expect(page.getByText("Could not change this source")).toBeVisible(); + // The switch went back on; the editor shows that, since it was not edited there. + await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).toBeChecked(); + await page.unroute("**/rest/v1/sources?**", failSwitch); + await sheet.getByRole("textbox", { name: "Content" }).fill("Edited text."); + await sheet.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Source updated")).toBeVisible(); + const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Race study")).data; + await expect.poll(row).toEqual([{ content: "Edited text.", is_active: true }]); + await evidence.shot("01-editor-after-rollback"); + + // Typing on while a save runs keeps that text as an edit, so the next Save sends it. + const slowSave = async (route: import("@playwright/test").Route) => { + if (route.request().method() === "PATCH") await new Promise((resolve) => setTimeout(resolve, 1_500)); + await route.continue(); + }; + await page.route("**/rest/v1/sources?**", slowSave); + const content = sheet.getByRole("textbox", { name: "Content" }); + await content.fill("Second edit."); + await sheet.getByRole("button", { name: "Save" }).click(); + await content.fill("Second edit, typed on."); + await expect.poll(row).toEqual([{ content: "Second edit.", is_active: true }]); + await expect(sheet.getByRole("button", { name: "Save" })).toBeEnabled(); + await page.unroute("**/rest/v1/sources?**", slowSave); + await sheet.getByRole("button", { name: "Save" }).click(); + await expect.poll(row).toEqual([{ content: "Second edit, typed on.", is_active: true }]); + await evidence.json("02-row", await row()); +}); + +test("lib-dedupe and lib-layout: a page saved once per debate, and long titles keep their room on a phone", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + const userId = await user.userId(); + const { data: debateRow } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single(); + const title = "Por qué deberían prohibirse los deberes escolares en la educación primaria según los expertos"; + const url = "https://www.example.org/estudios/deberes-escolares-primaria/"; + await admin.from("sources").insert({ user_id: userId, debate_id: debateRow!.id, title, content: "Texto", type: "url", origin: url, folder: "General", is_active: true }); + await page.setViewportSize({ width: 360, height: 780 }); + await page.goto("/app/library"); + const titleButton = page.getByRole("button", { name: title, exact: true }); + await expect(titleButton).toBeVisible(); + // Before, the controls shared the title's line and left it a few letters. + expect((await titleButton.boundingBox())!.width).toBeGreaterThan(200); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); + await evidence.shot("01-phone-row"); + + // The same page with tracking and a fragment is refused before any fetch. + let imported = false; + page.on("request", (request) => { if (request.url().includes("/functions/v1/import-source")) imported = true; }); + await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("textbox", { name: "Import from URL" }).fill("http://example.org/estudios/deberes-escolares-primaria?utm_source=x#top"); + await page.getByRole("button", { name: "Import URL" }).click(); + await expect(page.getByText("This page is already in this debate's Library.")).toBeVisible(); + expect(imported).toBe(false); + await evidence.shot("02-duplicate-warning"); + const { data } = await admin.from("sources").select("id").eq("user_id", userId); + expect(data).toHaveLength(1); +}); + +test("lib-counts: the debate summary and Ask agree on how many sources a request uses", async ({ signedIn: page, user, evidence }) => { + await startDebate(page, motion); + const userId = await user.userId(); + const { data: debateRow } = await admin.from("debates").select("id").eq("user_id", userId).eq("motion", motion).single(); + // What Attack's research leaves behind: several pages switched on, some off. + await admin.from("sources").insert([1, 2, 3, 4, 5, 6, 7].map((n) => ({ + user_id: userId, debate_id: debateRow!.id, title: `Researched page ${n}`, content: `Finding ${n}`, type: "url", + origin: `https://example.org/page-${n}`, folder: "Auto-saved", is_active: n <= 5, + }))); + await page.goto("/app"); + await expect(page.getByTestId("home-current-debate")).toContainText("7 sources (5 active)"); + await evidence.shot("01-home-counts"); + await page.goto("/app/ask"); + await expect(page.getByTestId("debate-context-control")).toContainText("Using 5 sources"); + await evidence.shot("02-ask-counts"); +}); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index c24431ef6..2fc4129bf 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -84,6 +84,24 @@ test("cross-turn and cross-debrief: questioning the AI gets an answer, a Jev sig await expectCrossfireSaved(await user.userId()); // The turn and the debrief were shown live (features/live-generation.md). await expectStreamed(["crossfire"]); + + // cross-history: after leaving the page, History reopens the round and can delete it. + await page.goto("/app/history?tab=crossfire"); + const row = page.getByTestId("history-crossfire-row"); + await expect(row).toHaveCount(1); + await expect(row).toContainText(resolution); + await row.getByRole("button", { name: `Open Crossfire round: ${resolution}` }).click(); + const saved = page.getByTestId("saved-crossfire"); + await expect(saved.getByTestId("saved-crossfire-debrief")).toBeVisible(); + await expect(saved.getByTestId("saved-crossfire-transcript")).toContainText(text!.slice(0, 40)); + await evidence.shot("04-history-crossfire"); + await evidence.aria("04-history-crossfire", saved); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: `Delete Crossfire round: ${resolution}` }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Delete" }).click(); + await expect(row).toHaveCount(0); + await expect.poll(async () => (await admin.from("crossfires").select("id").eq("user_id", await user.userId())).data).toEqual([]); + await evidence.step("Reopened the round from History, then deleted it"); }); test("cross-ai-questions: the AI questions the user, answers are judged, and the debrief completes", async ({ signedIn: page, user, evidence }) => { diff --git a/docs/architecture.md b/docs/architecture.md index b4d6b739e..175f01ef8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,9 @@ The repository is an npm workspace. The web app is the root package and still li [`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are. -Discovered-source autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) keeps one Library row per page per debate. It matches on a normalized URL (host without `www.`, no scheme, fragment, tracking parameters or trailing slash) and refreshes the existing row's research metadata instead of inserting a second one. Batches run one at a time within a tab; two tabs can still race, which only a unique index would stop. +The Library keeps one row per web page per debate. [`src/lib/source-url.ts`](../src/lib/source-url.ts) normalizes URLs (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). `addSource` in the session store refuses a page the debate already holds, checking the loaded Library and then the debate's rows on the server, so manual adds, URL imports and research autosave all go through the same check; Undo is exempt. Autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) refreshes the existing row's research metadata instead, and its batches run one at a time within a tab. Two tabs inserting the same page at the same moment can still both pass the check; only a unique index would stop that, and existing duplicate rows on production keep one from being added. + +A new source shows in the Library at once as a `pending-` row and is swapped for the saved row by id. Every write that turns sources on or off (the switch, a folder's Activate/Deactivate, the source editor) joins a per-source queue in the session store: the server applies them in click order, the newest decides what shows, a failure falls back to the last value the server accepted, and a server reload does not undo a write made while it ran. [`src/hooks/use-subscription.ts`](../src/hooks/use-subscription.ts) estimates today's remaining AI requests from the account's `ai_requests` rows (refused calls and `jev_*` helper rows excluded) and its own `rate_limits` override. Global `rate_limits` rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The `ai` function's 402 stays the decision, and every tool shows it with the same daily-limit dialog. diff --git a/src/components/features/crossfire/SavedCrossfireView.tsx b/src/components/features/crossfire/SavedCrossfireView.tsx new file mode 100644 index 000000000..158f03fe7 --- /dev/null +++ b/src/components/features/crossfire/SavedCrossfireView.tsx @@ -0,0 +1,182 @@ +import { useTranslation } from 'react-i18next'; +import { AlertCircle, MessageSquare, ShieldCheck } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { + CrossfireDebriefSchema, + CrossfireTranscriptSchema, + type CrossfireDebrief, + type CrossfireTurn, +} from '@/lib/crossfire/schemas'; +import { cn } from '@/lib/utils'; + +export interface SavedCrossfire { + turns: CrossfireTurn[]; + debrief: CrossfireDebrief | null; + endedReason?: 'manual' | 'timer'; +} + +/** + * Reads a recorded Crossfire round at the trust boundary. Older or damaged + * rows that do not match the current shape come back null, and the debrief + * alone may be missing. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function parseSavedCrossfire(output: unknown): SavedCrossfire | null { + if (!output || typeof output !== 'object' || Array.isArray(output)) return null; + const record = output as Record<string, unknown>; + const turns = CrossfireTranscriptSchema.safeParse(record.turns); + if (!turns.success) return null; + const debrief = CrossfireDebriefSchema.safeParse(record.debrief); + const endedReason = record.endedReason === 'manual' || record.endedReason === 'timer' ? record.endedReason : undefined; + return { turns: turns.data, debrief: debrief.success ? debrief.data : null, ...(endedReason ? { endedReason } : {}) }; +} + +interface ReferenceItem { + turnIds: string[]; + claim: string; +} + +// The same markup as the live debrief on the Crossfire page, so a reopened +// round reads the way it did when it ended. References jump to the turns below. +function TurnReferences({ turnIds }: { turnIds: string[] }) { + const { t } = useTranslation(); + return ( + <span className="flex flex-wrap gap-1.5" aria-label={t('crossfire.transcriptReferences')}> + {turnIds.map((turnId) => ( + <a + key={turnId} + href={`#crossfire-turn-${turnId}`} + className="rounded-md border border-primary/25 bg-primary/10 px-2 py-1 text-xs font-semibold text-primary hover:bg-primary/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + {turnId} + </a> + ))} + </span> + ); +} + +function DebriefSection({ title, items, emptyLabel }: { title: string; items: ReferenceItem[]; emptyLabel: string }) { + return ( + <section className="space-y-3" aria-label={title}> + <h3 className="text-base font-semibold text-foreground">{title}</h3> + {items.length === 0 ? ( + <p className="rounded-lg bg-muted/45 px-4 py-3 text-sm text-muted-foreground">{emptyLabel}</p> + ) : ( + <ul className="space-y-2"> + {items.map((item, index) => ( + <li key={`${item.turnIds.join('-')}-${index}`} className="space-y-2 rounded-lg border border-border/70 bg-card p-4"> + <p className="text-sm leading-relaxed text-foreground">{item.claim}</p> + <TurnReferences turnIds={item.turnIds} /> + </li> + ))} + </ul> + )} + </section> + ); +} + +function Debrief({ debrief }: { debrief: CrossfireDebrief }) { + const { t } = useTranslation(); + const empty = t('crossfire.noGroundedFinding', 'No grounded finding in this transcript.'); + return ( + <div data-testid="saved-crossfire-debrief" className="space-y-6"> + <h3 className="text-lg font-bold tracking-tight">{t('crossfire.debriefTitle')}</h3> + <div className="grid gap-6"> + <DebriefSection title={t('crossfire.concessionsSecured')} items={debrief.concessionsSecured} emptyLabel={empty} /> + <DebriefSection title={t('crossfire.controlledQuestions')} items={debrief.controlledQuestions} emptyLabel={empty} /> + <DebriefSection title={t('crossfire.weakOrRepetitiveQuestions')} items={debrief.weakOrRepetitiveQuestions} emptyLabel={empty} /> + <DebriefSection title={t('crossfire.evasiveOrIncompleteAnswers')} items={debrief.evasiveOrIncompleteAnswers} emptyLabel={empty} /> + <DebriefSection title={t('crossfire.missedFollowUps')} items={debrief.missedFollowUps} emptyLabel={empty} /> + <section className="space-y-3" aria-label={t('crossfire.betterFollowUpQuestions')}> + <h3 className="text-base font-semibold">{t('crossfire.betterFollowUpQuestions')}</h3> + {debrief.betterFollowUpQuestions.length === 0 ? ( + <p className="rounded-lg bg-muted/45 px-4 py-3 text-sm text-muted-foreground">{empty}</p> + ) : ( + <ul className="space-y-2"> + {debrief.betterFollowUpQuestions.map((item, index) => ( + <li key={`${item.turnIds.join('-')}-${index}`} className="space-y-2 rounded-lg border border-border/70 bg-card p-4"> + <p className="font-medium text-foreground">{item.question}</p> + <p className="text-sm text-muted-foreground">{item.reason}</p> + <TurnReferences turnIds={item.turnIds} /> + </li> + ))} + </ul> + )} + </section> + </div> + <section className="rounded-xl border border-primary/25 bg-primary/5 p-5" aria-label={t('crossfire.strongestExchange')}> + <div className="mb-3 flex items-center gap-2"> + <ShieldCheck className="h-5 w-5 text-primary" /> + <h3 className="font-semibold">{t('crossfire.strongestExchange')}</h3> + </div> + {debrief.strongestExchange ? ( + <div className="space-y-3"> + <p className="text-sm leading-relaxed">{debrief.strongestExchange.claim}</p> + <TurnReferences turnIds={debrief.strongestExchange.turnIds} /> + </div> + ) : ( + <p className="text-sm text-muted-foreground">{empty}</p> + )} + </section> + </div> + ); +} + +function Transcript({ turns }: { turns: CrossfireTurn[] }) { + const { t } = useTranslation(); + return ( + <section data-testid="saved-crossfire-transcript" aria-label={t('crossfire.transcript')} className="space-y-3"> + <div className="flex items-center gap-2"> + <MessageSquare className="h-4 w-4 text-primary" /> + <h3 className="font-semibold">{t('crossfire.transcript')}</h3> + </div> + <div className="space-y-3"> + {turns.map((turn) => ( + <article + key={turn.id} + id={`crossfire-turn-${turn.id}`} + className={cn( + 'scroll-mt-4 rounded-lg border p-4', + turn.speaker === 'user' ? 'ml-auto max-w-[92%] border-primary/25 bg-primary/5' : 'mr-auto max-w-[92%] border-border bg-card', + )} + > + <div className="mb-2 flex items-center justify-between gap-4 text-xs text-muted-foreground"> + <span className="font-semibold text-foreground">{turn.id}</span> + <span>{turn.kind === 'question' ? t('crossfire.question') : t('crossfire.answer')}</span> + </div> + <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">{turn.text}</p> + {turn.kind === 'answer' && turn.signal ? ( + <div className="mt-3 flex flex-wrap gap-1.5" aria-label={t('crossfire.liveSignals')}> + <Badge variant="outline">{t(`crossfire.signalQuality.${turn.signal.answerQuality}`)}</Badge> + <Badge variant="outline">{t(`crossfire.signalConcession.${turn.signal.concession}`)}</Badge> + {turn.signal.followUpOpportunity > 0.70 ? <Badge variant="secondary">{t('crossfire.signalFollowUp')}</Badge> : null} + </div> + ) : null} + </article> + ))} + </div> + </section> + ); +} + +/** A finished Crossfire round from History: the debrief, then the transcript it cites. */ +export function SavedCrossfireView({ output }: { output: unknown }) { + const { t } = useTranslation(); + const round = parseSavedCrossfire(output); + if (!round) { + return ( + <p className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground" role="status"> + <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" /> + {t('historyPage.crossfireUnreadable', 'This round was saved in an older format and cannot be shown.')} + </p> + ); + } + return ( + <div className="space-y-8"> + {round.debrief ? <Debrief debrief={round.debrief} /> : ( + <p className="rounded-lg bg-muted/45 px-4 py-3 text-sm text-muted-foreground">{t('historyPage.crossfireNoDebrief', 'This round has no debrief.')}</p> + )} + <Transcript turns={round.turns} /> + </div> + ); +} diff --git a/src/components/features/debate/CurrentDebateMenu.tsx b/src/components/features/debate/CurrentDebateMenu.tsx index bdef996f7..ba8897788 100644 --- a/src/components/features/debate/CurrentDebateMenu.tsx +++ b/src/components/features/debate/CurrentDebateMenu.tsx @@ -81,7 +81,7 @@ export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' } {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} </p> <p className="text-xs text-muted-foreground"> - {t('debate.counts', { sources: counts.sources, outputs: counts.outputs, defaultValue: '{{sources}} sources · {{outputs}} outputs' })} + {t('debate.counts', { sources: counts.sources, active: counts.activeSources, outputs: counts.outputs, defaultValue: '{{sources}} sources ({{active}} active) · {{outputs}} outputs' })} </p> {oneOff && <p className="text-xs font-medium text-primary">{t('debate.oneOffActive', 'One-off mode is on')}</p>} </div> diff --git a/src/components/features/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx index 5833b1a5e..5d2ab46da 100644 --- a/src/components/features/debate/CurrentDebateStrip.tsx +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { shouldPrefillMotion } from '@/lib/debate/motion'; import { useCurrentDebate, useDebateStore, type DebateSide } from '@/lib/stores/debate-store'; -import { useSessionStore } from '@/lib/stores/session-store'; +import { isPendingSource, useSessionStore } from '@/lib/stores/session-store'; import { useSparringStore } from '@/lib/stores/sparring-store'; // eslint-disable-next-line react-refresh/only-export-components @@ -12,16 +12,22 @@ export function sideLabelKey(side: DebateSide): string { return `debate.side.${side}`; } -/** Sources and saved outputs that belong to one debate. */ +/** + * Sources and saved outputs that belong to one debate. `activeSources` counts + * the sources switched on, the ones a request can use, so every page that + * names a count agrees with what Ask, Generate and the others say they use. + */ // eslint-disable-next-line react-refresh/only-export-components export function useDebateCounts(debateId: string | null | undefined) { - const sources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId).length : 0); + // A row still saving is not counted until it is a source the tools can send. + const sources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId && !isPendingSource(source)).length : 0); + const activeSources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId && source.isActive && !isPendingSource(source)).length : 0); const outputs = useSessionStore((state) => debateId ? [ state.savedCases, state.savedAttacks, state.savedBrainstorms, state.savedFactoids, state.savedChats, state.savedFlowcharts, state.savedCrossfires, ].reduce((count, list) => count + list.filter((item) => item.debateId === debateId).length, 0) : 0); const sparring = useSparringStore((state) => debateId ? state.savedSessions.filter((session) => session.debateId === debateId).length : 0); - return { sources, outputs: outputs + sparring }; + return { sources, activeSources, outputs: outputs + sparring }; } /** diff --git a/src/lib/ai/sources.test.ts b/src/lib/ai/sources.test.ts index 21ee55b6d..3b9671421 100644 --- a/src/lib/ai/sources.test.ts +++ b/src/lib/ai/sources.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from '@/hooks/use-toast'; import { autosaveDiscoveredSources, normalizeSourceUrl } from './sources'; + +vi.mock('@/hooks/use-toast', () => ({ toast: vi.fn() })); import { useSessionStore, type Source } from '@/lib/stores/session-store'; const DEBATE = 'debate-1'; @@ -31,6 +34,7 @@ describe('autosaveDiscoveredSources', () => { const original = useSessionStore.getState(); beforeEach(() => { + vi.mocked(toast).mockReset(); addSource.mockReset(); updateSource.mockReset(); // The stubbed write lands in the library like the real one, a moment later. @@ -89,4 +93,18 @@ describe('autosaveDiscoveredSources', () => { expect(addSource).toHaveBeenCalledTimes(2); }); + + it('says how many sources research added, counting only new rows', async () => { + await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'generate', DEBATE); + vi.mocked(toast).mockReset(); + + await autosaveDiscoveredSources([ + { title: 'Homework study', url: `${PAGE}?utm_source=exa` }, + { title: 'A', url: 'https://example.org/a' }, + { title: 'B', url: 'https://example.org/b' }, + ], 'attack', DEBATE); + + expect(toast).toHaveBeenCalledOnce(); + expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your Library' }); + }); }); diff --git a/src/lib/ai/sources.ts b/src/lib/ai/sources.ts index 6a1feac9e..07d4245e2 100644 --- a/src/lib/ai/sources.ts +++ b/src/lib/ai/sources.ts @@ -1,4 +1,5 @@ import { + DuplicateSourceError, useSessionStore, type SourceAssessmentUpdate, type SourceProvenance, @@ -7,6 +8,7 @@ import { import { activeDebateId } from '../stores/debate-store'; import { toast } from '@/hooks/use-toast'; import { validateUrl } from '@/lib/validation/url'; +import { findSavedPage, normalizeSourceUrl } from '@/lib/source-url'; import i18n from '@/i18n'; /** A web source discovered during research, with auditable ranking metadata from the edge function. */ @@ -117,36 +119,7 @@ export function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] type AutosaveFeature = 'generate' | 'attack' | 'brainstorm' | 'factoids'; -// Query parameters that only say where a click came from. Two links that differ -// only in these open the same page. -const TRACKING_PARAMS = /^(utm_.+|fbclid|gclid|dclid|gbraid|wbraid|msclkid|yclid|mc_cid|mc_eid|igshid|_ga|_gl|ref_src)$/i; - -/** - * One key per web page: the host lower-cased without "www.", no scheme, no - * fragment, no tracking parameters, no trailing slash. Null when it is not a URL. - */ -export function normalizeSourceUrl(url: string): string | null { - let parsed: URL; - try { - parsed = new URL(url.trim()); - } catch { - return null; - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; - const host = parsed.host.toLowerCase().replace(/^www\./, ''); - const params = new URLSearchParams(); - parsed.searchParams.forEach((value, key) => { - if (!TRACKING_PARAMS.test(key)) params.append(key, value); - }); - const query = params.toString(); - const path = parsed.pathname.replace(/\/+$/, ''); - return `${host}${path}${query ? `?${query}` : ''}`; -} - -function savedSourceUrl(source: { origin?: string; metadata?: { url?: string } }): string | null { - const url = source.metadata?.url ?? source.origin; - return url ? normalizeSourceUrl(url) : null; -} +export { normalizeSourceUrl }; // Generate, Attack, Brainstorm and Factoids can finish at the same time. Each // batch waits for the one before it, so it sees the rows that batch saved. @@ -176,6 +149,7 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF }); if (retainedSources.length === 0) return; + let added = 0; for (const source of retainedSources) { const session = useSessionStore.getState(); const metadata = { @@ -187,10 +161,7 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF ...(source.semantic !== undefined ? { semantic: source.semantic } : {}), capturedVia, }; - const key = normalizeSourceUrl(source.url); - const existing = key - ? session.sourceLibrary.find((saved) => saved.debateId === targetDebateId && savedSourceUrl(saved) === key) - : undefined; + const existing = findSavedPage(session.sourceLibrary, targetDebateId, source.url); try { if (existing) { // Already in this debate's Library: refresh what research learned about @@ -211,7 +182,10 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate', metadata, }); - } catch { + added += 1; + } catch (error) { + // Another tab saved this page first; the debate already has it. + if (error instanceof DuplicateSourceError) continue; toast({ title: i18n.t('aiClient.sourceSaveFailed'), description: i18n.t('aiClient.sourceSaveFailedDesc', { title: source.title }), @@ -219,5 +193,11 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF }); } } - console.log(`[AI Client] Saved ${retainedSources.length} sources to library`); + // Research changes what grounds the next request, so say so. + if (added > 0) { + toast({ + title: i18n.t('aiClient.sourcesAutoSaved', { count: added }), + description: i18n.t('aiClient.sourcesAutoSavedDesc'), + }); + } } diff --git a/src/lib/source-url.ts b/src/lib/source-url.ts new file mode 100644 index 000000000..0e81aff32 --- /dev/null +++ b/src/lib/source-url.ts @@ -0,0 +1,42 @@ +// Query parameters that only say where a click came from. Two links that differ +// only in these open the same page. +const TRACKING_PARAMS = /^(utm_.+|fbclid|gclid|dclid|gbraid|wbraid|msclkid|yclid|mc_cid|mc_eid|igshid|_ga|_gl|ref_src)$/i; + +/** + * One key per web page: the host lower-cased without "www.", no scheme, no + * fragment, no tracking parameters, no trailing slash. Null when it is not a URL. + */ +export function normalizeSourceUrl(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + const host = parsed.host.toLowerCase().replace(/^www\./, ''); + const params = new URLSearchParams(); + parsed.searchParams.forEach((value, key) => { + if (!TRACKING_PARAMS.test(key)) params.append(key, value); + }); + const query = params.toString(); + const path = parsed.pathname.replace(/\/+$/, ''); + return `${host}${path}${query ? `?${query}` : ''}`; +} + +/** The page a saved source came from: research keeps it in metadata, imports in origin. */ +export function savedSourceUrl(source: { origin?: string; metadata?: { url?: string } }): string | null { + const url = source.metadata?.url ?? source.origin; + return url ? normalizeSourceUrl(url) : null; +} + +/** The source in `debateId` that already holds this page, if any. */ +export function findSavedPage<T extends { debateId?: string | null; origin?: string; metadata?: { url?: string } }>( + sources: T[], + debateId: string | null | undefined, + url: string | undefined, +): T | undefined { + const key = url ? normalizeSourceUrl(url) : null; + if (!key || !debateId) return undefined; + return sources.find((source) => source.debateId === debateId && savedSourceUrl(source) === key); +} diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 24faccfe2..32ba3dc78 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -11,6 +11,7 @@ import { supabase } from '@/lib/supabase/client'; import { restoredSessionsBlocked } from '@/lib/supabase/session-expiry'; import { currentUserId } from '@/lib/supabase/current-user'; import i18n from '@/i18n'; +import { findSavedPage, normalizeSourceUrl, savedSourceUrl } from '@/lib/source-url'; import { activeDebateId, useDebateStore, type DebateSide } from './debate-store'; interface Message { @@ -215,12 +216,17 @@ export interface SessionState { recordResult: (kind: ResultKind, input: RecordResultInput) => Promise<string>; updateRecordedResult: (kind: ResultKind, id: string, output: unknown) => Promise<void>; + deleteRecordedResult: (kind: ResultKind, id: string) => Promise<void>; attachItem: (kind: AttachKind, id: string, debateId: string, side?: DebateSide) => Promise<void>; /** Records new Ask messages: into the loaded chat, or a new chat in `debateId`. */ syncAskChat: (debateId: string | null) => Promise<void>; deleteSavedFlowchart: (id: string) => Promise<void>; - /** `id` and `createdAt` are only for Undo, which puts a deleted source back exactly. */ + /** + * Shows the source at once and saves it. `id` and `createdAt` are only for + * Undo, which puts a deleted source back exactly. Throws DuplicateSourceError + * when the debate already holds the same web page. + */ addSource: (source: Omit<Source, 'id' | 'createdAt' | 'isActive'> & { isActive?: boolean; id?: string; createdAt?: number }) => Promise<void>; createSourceFolder: (folder: string) => Promise<void>; updateSource: (id: string, patch: Partial<Omit<Source, 'id' | 'createdAt'>>) => Promise<void>; @@ -452,6 +458,97 @@ export class DebateRequiredError extends Error { } } +/** Thrown when the debate already has a source for the same web page. */ +export class DuplicateSourceError extends Error { + constructor(readonly existingId: string) { + super('This page is already in the debate\'s Library'); + this.name = 'DuplicateSourceError'; + } +} + +const PENDING_SOURCE_PREFIX = 'pending-'; + +/** Counts source switch writes, so a server reload can tell which ones it predates. */ +let sourceWriteSeq = 0; +/** Writes to a source's switch still running, and the last value the server accepted. */ +const sourceToggles = new Map<string, { latest: number; confirmed: boolean; chain: Promise<void> }>(); +/** The newest switch value per source, kept so a reload that started earlier does not undo it. */ +const lastSourceToggle = new Map<string, { value: boolean; seq: number }>(); + +interface SourceActiveWrite { + seq: number; + value: boolean; + entries: Array<[string, { latest: number; confirmed: boolean; chain: Promise<void> }]>; + done: Promise<void>; +} + +/** + * Every write that changes whether sources are on (the switch, a folder's + * Activate or Deactivate, the editor's Save) joins each source's queue, so the + * server applies them in the order the student made them. Call it before + * showing the new value; finishSourceActiveWrite then decides what shows. + */ +function beginSourceActiveWrite(ids: string[], value: boolean, write: () => Promise<void>): SourceActiveWrite { + const seq = ++sourceWriteSeq; + const shown = useSessionStore.getState().sourceLibrary; + const entries = ids.map((id): SourceActiveWrite['entries'][number] => { + const entry = sourceToggles.get(id) ?? { + latest: 0, + confirmed: shown.find((source) => source.id === id)?.isActive ?? value, + chain: Promise.resolve(), + }; + entry.latest = seq; + sourceToggles.set(id, entry); + lastSourceToggle.set(id, { value, seq }); + return [id, entry]; + }); + const done = Promise.all(entries.map(([, entry]) => entry.chain)).then(write); + for (const [, entry] of entries) entry.chain = done.catch((): void => undefined); + return { seq, value, entries, done }; +} + +/** + * Shows the newest write's value once it lands, or the last value the server + * took if it fails. With `quietWhenReplaced`, a failure is not reported when a + * newer write already owns every source: that write's outcome is what shows. + */ +async function finishSourceActiveWrite({ seq, value, entries, done }: SourceActiveWrite, quietWhenReplaced = false): Promise<void> { + const show = (values: Map<string, boolean>) => { + if (values.size === 0) return; + useSessionStore.setState((state) => ({ + sourceLibrary: state.sourceLibrary.map((source) => values.has(source.id) ? { ...source, isActive: values.get(source.id)! } : source), + })); + }; + try { + await done; + } catch (error) { + const fallback = new Map<string, boolean>(); + for (const [id, entry] of entries) { + if (entry.latest !== seq) continue; + sourceToggles.delete(id); + lastSourceToggle.set(id, { value: entry.confirmed, seq }); + fallback.set(id, entry.confirmed); + } + show(fallback); + if (quietWhenReplaced && fallback.size === 0) return; + throw error; + } + const settled = new Map<string, boolean>(); + for (const [id, entry] of entries) { + entry.confirmed = value; + // A server reload while this ran may have put back the old value. + if (entry.latest !== seq) continue; + sourceToggles.delete(id); + settled.set(id, value); + } + show(settled); +} + +/** A source shown before its row is saved; it has no server id to change yet. */ +export function isPendingSource(source: Pick<Source, 'id'>): boolean { + return source.id.startsWith(PENDING_SOURCE_PREFIX); +} + function savedFromRow<T>(row: SupabaseSavedRow<T>): SavedItem<T> { return { id: row.id, @@ -575,52 +672,98 @@ export const useSessionStore = create<SessionState>()( // An omitted debate means the tool's scope, which is none in one-off mode. const debateId = source.debateId !== undefined ? source.debateId : activeDebateId(); if (!debateId) throw new DebateRequiredError(); - const localItem: Source = { ...source, folder, debateId, id: restoredId ?? generateId(), createdAt: restoredAt ?? Date.now(), isActive }; + // One row per web page per debate. Undo puts back a row that was just deleted. + const url = source.metadata?.url ?? source.origin; + if (!restoredId) { + const known = findSavedPage(get().sourceLibrary, debateId, url); + if (known) throw new DuplicateSourceError(known.id); + } + // Other actions copy rows as they change them, so the shown row is found + // again by its id. A restored row is pending too until its insert lands. + const localId = `${PENDING_SOURCE_PREFIX}${restoredId ?? generateId()}`; + const localItem: Source = { ...source, folder, debateId, isActive, id: localId, createdAt: restoredAt ?? Date.now() }; // The library is newest first; a restored source goes back to its old place. const place = (list: Source[], item: Source) => restoredId ? [...list, item].sort((x, y) => y.createdAt - x.createdAt) : [item, ...list]; - const userId = await getUserId(); - if (!userId) throw signInRequired('save this source'); + // The row shows while it saves, and leaves again if the save fails. + set((state) => ({ sourceLibrary: place(state.sourceLibrary, localItem) })); + const withoutLocal = () => set((state) => ({ sourceLibrary: state.sourceLibrary.filter((item) => item.id !== localId) })); - const folderResult = await supabase - .from('source_folders') - .upsert({ user_id: userId, name: folder }, { onConflict: 'user_id,name' }); - throwOnWriteError(folderResult, 'save source folder'); - const { data, error } = await supabase - .from('sources') - .insert({ - ...(restoredId ? { id: restoredId, created_at: new Date(localItem.createdAt).toISOString() } : {}), - user_id: userId, - debate_id: debateId, - title: source.title, - content: source.content, - type: source.type, - origin: source.origin || null, - folder, - is_active: isActive, - metadata: source.metadata ?? null, - }) - .select('*') - .single(); - throwOnWriteError({ error }, 'save source'); - if (!data) throw new Error('[SessionStore] Failed to save source: No record returned'); - - const savedSource: Source = { - id: data.id, - title: data.title, - content: data.content, - type: data.type, - origin: data.origin || undefined, - folder: normalizeFolder(data.folder || localItem.folder), - metadata: parseSourceProvenance(data.metadata), - debateId: data.debate_id ?? debateId, - isActive: data.is_active, - createdAt: toTimestamp(data.created_at), - }; - set((state) => ({ - sourceFolders: mergeSourceFolders(state.sourceFolders, [savedSource]), - sourceLibrary: place(state.sourceLibrary, savedSource), - })); + let savedSource: Source; + try { + const userId = await getUserId(); + if (!userId) throw signInRequired('save this source'); + + const key = !restoredId && url ? normalizeSourceUrl(url) : null; + if (key) { + // Another tab, or a list that failed to load, may hold the page already. + const { data: rows, error: lookupError } = await supabase + .from('sources') + .select('id,origin,metadata') + .eq('user_id', userId) + .eq('debate_id', debateId); + throwOnWriteError({ error: lookupError }, 'check for a saved copy of this page'); + const match = (rows ?? []).find((row) => savedSourceUrl({ + origin: row.origin ?? undefined, + metadata: parseSourceProvenance(row.metadata), + }) === key); + if (match) throw new DuplicateSourceError(match.id); + } + + const folderResult = await supabase + .from('source_folders') + .upsert({ user_id: userId, name: folder }, { onConflict: 'user_id,name' }); + throwOnWriteError(folderResult, 'save source folder'); + const { data, error } = await supabase + .from('sources') + .insert({ + ...(restoredId ? { id: restoredId, created_at: new Date(localItem.createdAt).toISOString() } : {}), + user_id: userId, + debate_id: debateId, + title: source.title, + content: source.content, + type: source.type, + origin: source.origin || null, + folder, + is_active: isActive, + metadata: source.metadata ?? null, + }) + .select('*') + .single(); + throwOnWriteError({ error }, 'save source'); + if (!data) throw new Error('[SessionStore] Failed to save source: No record returned'); + + savedSource = { + id: data.id, + title: data.title, + content: data.content, + type: data.type, + origin: data.origin || undefined, + folder: normalizeFolder(data.folder || localItem.folder), + metadata: parseSourceProvenance(data.metadata), + debateId: data.debate_id ?? debateId, + isActive: data.is_active, + createdAt: toTimestamp(data.created_at), + }; + } catch (error) { + withoutLocal(); + throw error; + } + set((state) => { + const shown = state.sourceLibrary.some((item) => item.id === localId); + // A reload from the server while this saved may already list the row. + const listed = state.sourceLibrary.some((item) => item.id === savedSource.id); + // A debate deleted meanwhile took its sources with it. + const debateGone = !useDebateStore.getState().debates.some((debate) => debate.id === debateId); + const sourceLibrary = shown + ? listed + ? state.sourceLibrary.filter((item) => item.id !== localId) + : state.sourceLibrary.map((item) => item.id === localId ? savedSource : item) + : listed || debateGone + ? state.sourceLibrary + : place(state.sourceLibrary, savedSource); + return { sourceFolders: mergeSourceFolders(state.sourceFolders, [savedSource]), sourceLibrary }; + }); markDebateActivity(debateId); }, createSourceFolder: async (folder) => { @@ -639,7 +782,8 @@ export const useSessionStore = create<SessionState>()( }, updateSource: async (id, patch) => { const current = get().sourceLibrary.find((s) => s.id === id); - if (!current) return; + // A row still saving has nothing on the server to update yet. + if (!current || isPendingSource(current)) return; const normalizedPatch: Partial<Omit<Source, 'id' | 'createdAt'>> = { ...patch }; if (patch.folder !== undefined) { @@ -648,46 +792,55 @@ export const useSessionStore = create<SessionState>()( if (patch.origin !== undefined) { normalizedPatch.origin = patch.origin.trim() || undefined; } - const nextSource: Source = { - ...current, - ...normalizedPatch, - title: normalizedPatch.title?.trim() || current.title, - content: normalizedPatch.content?.trim() || current.content, - }; - - const previousFolders = get().sourceFolders; - set((state) => ({ - sourceFolders: mergeSourceFolders(state.sourceFolders, [nextSource]), - sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? nextSource : source), - })); - - try { + if (patch.title !== undefined) normalizedPatch.title = patch.title.trim() || current.title; + if (patch.content !== undefined) normalizedPatch.content = patch.content.trim() || current.content; + // Only what this edit changes is written. The switch is left alone unless + // the edit changes it, so a refresh of research metadata cannot save a + // switch value whose own write is still running or about to fail. + const changesActive = patch.isActive !== undefined && patch.isActive !== current.isActive; + delete normalizedPatch.isActive; + const row: Record<string, unknown> = {}; + if (normalizedPatch.title !== undefined) row.title = normalizedPatch.title; + if (normalizedPatch.content !== undefined) row.content = normalizedPatch.content; + if (normalizedPatch.type !== undefined) row.type = normalizedPatch.type; + if ('origin' in normalizedPatch) row.origin = normalizedPatch.origin || null; + if (normalizedPatch.folder !== undefined) row.folder = normalizedPatch.folder; + if ('metadata' in normalizedPatch) row.metadata = normalizedPatch.metadata ?? null; + if (changesActive) row.is_active = patch.isActive; + if (Object.keys(row).length === 0) return; + + const write = async () => { const userId = await getUserId(); if (!userId) throw signInRequired('save this source'); - if (nextSource.folder) { + if (normalizedPatch.folder) { const folderResult = await supabase .from('source_folders') - .upsert({ user_id: userId, name: nextSource.folder }, { onConflict: 'user_id,name' }); + .upsert({ user_id: userId, name: normalizedPatch.folder }, { onConflict: 'user_id,name' }); throwOnWriteError(folderResult, 'save source folder'); } - const result = await applyUserScopedIdFilter( - supabase.from('sources').update({ - title: nextSource.title, - content: nextSource.content, - type: nextSource.type, - origin: nextSource.origin || null, - folder: nextSource.folder || 'General', - is_active: nextSource.isActive, - metadata: nextSource.metadata ?? null, - }), - userId, - id, - ); - throwOnWriteError(result, 'update source'); + throwOnWriteError(await applyUserScopedIdFilter(supabase.from('sources').update(row), userId, id), 'update source'); + }; + const activeWrite = changesActive ? beginSourceActiveWrite([id], patch.isActive!, write) : null; + const previousFolders = get().sourceFolders; + // A failed save undoes only the fields it changed. + const undo: Partial<Source> = {}; + for (const key of Object.keys(normalizedPatch) as Array<keyof typeof normalizedPatch>) { + (undo as Record<string, unknown>)[key] = current[key]; + } + set((state) => ({ + sourceFolders: normalizedPatch.folder ? mergeSourceFolders(state.sourceFolders, [{ ...current, folder: normalizedPatch.folder }]) : state.sourceFolders, + sourceLibrary: state.sourceLibrary.map((source) => source.id === id + ? { ...source, ...normalizedPatch, ...(changesActive ? { isActive: patch.isActive! } : {}) } + : source), + })); + + try { + if (activeWrite) await finishSourceActiveWrite(activeWrite); + else await write(); } catch (error) { set((state) => ({ - sourceFolders: previousFolders, - sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? current : source), + sourceFolders: normalizedPatch.folder ? previousFolders : state.sourceFolders, + sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, ...undo } : source), })); throw error; } @@ -824,20 +977,26 @@ export const useSessionStore = create<SessionState>()( const normalized = normalizeFolder(folder); const debateId = useDebateStore.getState().currentDebateId; if (!debateId) throw new DebateRequiredError(); - const userId = await getUserId(); - if (!userId) throw signInRequired('update this folder'); - throwOnWriteError( - await supabase.from('sources').update({ is_active: isActive }) - .eq('user_id', userId).eq('debate_id', debateId).eq('folder', normalized), - 'update source folder activity', - ); - set((state) => ({ - sourceLibrary: state.sourceLibrary.map((source) => - source.debateId === debateId && normalizeFolder(source.folder) === normalized ? { ...source, isActive } : source - ), - })); + // Rows still saving are not on the server yet; they keep the value they are saved with. + const ids = get().sourceLibrary + .filter((source) => source.debateId === debateId && normalizeFolder(source.folder) === normalized && !isPendingSource(source)) + .map((source) => source.id); + if (ids.length === 0) return; + // Only the rows on screen: a row still saving could otherwise commit first + // and take the folder's value on the server but not in the Library. + const activeWrite = beginSourceActiveWrite(ids, isActive, async () => { + const userId = await getUserId(); + if (!userId) throw signInRequired('update this folder'); + throwOnWriteError( + await supabase.from('sources').update({ is_active: isActive }) + .eq('user_id', userId).eq('debate_id', debateId).in('id', ids), + 'update source folder activity', + ); + }); + await finishSourceActiveWrite(activeWrite); }, removeSource: async (id) => { + if (isPendingSource({ id })) throw new Error('[SessionStore] This source is still saving'); const userId = await getUserId(); if (!userId) throw signInRequired('delete this source'); const result = await applyUserScopedIdFilter(supabase.from('sources').delete(), userId, id); @@ -847,16 +1006,24 @@ export const useSessionStore = create<SessionState>()( toggleSource: async (id) => { const current = get().sourceLibrary.find((source) => source.id === id); if (!current) return; + if (isPendingSource(current)) throw new Error('[SessionStore] This source is still saving'); const nextValue = !current.isActive; - const userId = await getUserId(); - if (!userId) throw signInRequired('update this source'); - throwOnWriteError( - await applyUserScopedIdFilter(supabase.from('sources').update({ is_active: nextValue }), userId, id), - 'toggle source activity', - ); + // The switch moves at once. Writes for one source run in click order, and + // only the newest decides what shows once they settle; a failure falls + // back to the last value the server accepted, not to what was on screen. + const activeWrite = beginSourceActiveWrite([id], nextValue, async () => { + const userId = await getUserId(); + if (!userId) throw signInRequired('update this source'); + throwOnWriteError( + await applyUserScopedIdFilter(supabase.from('sources').update({ is_active: nextValue }), userId, id), + 'toggle source activity', + ); + }); set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive: nextValue } : source), })); + // A failure that a newer click already replaced changes nothing on screen, so it is not reported. + await finishSourceActiveWrite(activeWrite, true); }, clearLibrary: async () => { // The Library belongs to the Current Debate; other debates keep their sources. @@ -1122,6 +1289,19 @@ export const useSessionStore = create<SessionState>()( }) as Partial<SessionState>); }, + deleteRecordedResult: async (kind, id) => { + const userId = await getUserId(); + if (!userId) throw signInRequired('delete this result'); + throwOnWriteError( + await applyUserScopedIdFilter(supabase.from(RESULT_TABLES[kind]).delete(), userId, id), + `delete ${kind}`, + ); + const list = RESULT_LISTS[kind]; + set((state) => ({ + [list]: (state[list] as SavedItem<unknown>[]).filter((item) => item.id !== id), + }) as Partial<SessionState>); + }, + attachItem: async (kind, id, debateId, side) => { const { error } = await supabase.rpc('attach_to_debate', { p_kind: kind, @@ -1161,6 +1341,9 @@ export const useSessionStore = create<SessionState>()( hydrateFromServer: async () => { if (restoredSessionsBlocked()) return; + const hydrateSeq = sourceWriteSeq; + // A switch write already running may land after this read; keep its value too. + const writesInFlight = new Set(sourceToggles.keys()); const results = await Promise.allSettled([ supabase.from('cases').select('*').order('created_at', { ascending: false }), supabase.from('attacks').select('*').order('created_at', { ascending: false }), @@ -1238,7 +1421,7 @@ export const useSessionStore = create<SessionState>()( const sourceRows = sourcesData || []; const sourceFolderRows = sourceFoldersData || []; - const serverSources = sourceRows.map((row) => ({ + const serverSources = sourceRows.map((row): Source => ({ id: row.id, title: row.title, content: row.content, @@ -1264,7 +1447,16 @@ export const useSessionStore = create<SessionState>()( savedChats: chatsData ? serverChats : state.savedChats, savedFlowcharts: flowchartsData ? serverFlowcharts : state.savedFlowcharts, sourceFolders: mergeSourceFolders([...(sourceFoldersData ? [] : state.sourceFolders), ...serverSourceFolders], sourcesData ? serverSources : state.sourceLibrary), - sourceLibrary: sourcesData ? serverSources : state.sourceLibrary, + // Rows still saving stay, and a switch flipped after this read began keeps its value. + sourceLibrary: sourcesData + ? [ + ...state.sourceLibrary.filter(isPendingSource), + ...serverSources.map((source) => { + const toggle = lastSourceToggle.get(source.id); + return toggle && (toggle.seq > hydrateSeq || writesInFlight.has(source.id)) ? { ...source, isActive: toggle.value } : source; + }), + ] + : state.sourceLibrary, serverHydration: casesData && attacksData && brainstormsData && factoidsData && chatsData && crossfiresData ? 'complete' : 'partial', })); }, @@ -1352,7 +1544,11 @@ export const useSessionStore = create<SessionState>()( pendingFlowchartData, serverHydration, ...persisted - }) => persisted, + }) => ({ + ...persisted, + // A row still saving is not a source yet; the server list has it once it is. + sourceLibrary: persisted.sourceLibrary.filter((source) => !isPendingSource(source)), + }), } ) ); diff --git a/src/locales/en.json b/src/locales/en.json index 9ca1b560e..26a743d43 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -254,7 +254,10 @@ "autoSavedFolder": "Auto-saved", "sourceSaveFailed": "Source save failed", "sourceSaveFailedDesc": "Could not save “{{title}}” to your library.", - "sourceAssessmentSaveFailed": "The source ranking could not be saved." + "sourceAssessmentSaveFailed": "The source ranking could not be saved.", + "sourcesAutoSaved_one": "Added {{count}} source to your Library", + "sourcesAutoSaved_other": "Added {{count}} sources to your Library", + "sourcesAutoSavedDesc": "Research found them. They are in the Auto-saved folder, and the ones switched on ground your next requests." }, "generatedArtifacts": { "brainstormTitle": "Brainstorm", @@ -1612,7 +1615,24 @@ "caseDeleted": "Saved case deleted.", "deleteCaseDesc": "This removes the saved case from your history.", "deleteCaseTitle": "Delete saved case?", - "deleteFailed": "Could not delete saved case." + "deleteFailed": "Could not delete saved case.", + "tabCrossfire": "Crossfire", + "crossfireTitle": "Crossfire rounds", + "emptyCrossfire": "No Crossfire rounds yet", + "emptyCrossfireDesc": "Finish a Crossfire round to review its transcript and debrief here.", + "startCrossfire": "Start Crossfire", + "crossfireTurns_one": "{{count}} turn", + "crossfireTurns_other": "{{count}} turns", + "open": "Open", + "crossfireRound": "Crossfire round", + "deleteCrossfireAria": "Delete Crossfire round: {{title}}", + "deleteCrossfireTitle": "Delete Crossfire round?", + "deleteCrossfireDesc": "This removes the transcript and debrief from your history.", + "crossfireDeleted": "Crossfire round deleted.", + "crossfireDeleteFailed": "Could not delete the Crossfire round.", + "crossfireUnreadable": "This round was saved in an older format and cannot be shown.", + "crossfireNoDebrief": "This round has no debrief.", + "openCrossfireAria": "Open Crossfire round: {{title}}" }, "libraryPage": { "title": "Source Library", @@ -1713,7 +1733,16 @@ "mergeFolderTitle": "Merge into {{name}}?", "mergeFolders": "Merge folders", "mergeFailed": "Could not merge the folders. Try again.", - "openOrigin": "Open {{title}} in a new tab" + "openOrigin": "Open {{title}} in a new tab", + "savingSource": "Saving…", + "offlineTitle": "Change not saved", + "offlineNotSaved": "You are offline. Reconnect and try again.", + "toggleFailed": "Could not change this source", + "toggleFailedDesc": "It is back to how it was. Try again.", + "sourceSaved": "Saved {{title}} to the Library", + "duplicateUrlDesc": "This page is already in this debate's Library.", + "loadingSources": "Loading sources…", + "folderUpdateFailed": "Could not change this folder" }, "autoSavedSources": { "undo": "Undo auto-saved sources", @@ -2268,7 +2297,7 @@ "deleted": "Debate deleted", "deleteFailed": "Could not delete the debate", "lastActivity": "Last activity {{when}}", - "counts": "{{sources}} sources · {{outputs}} outputs", + "counts": "{{sources}} sources ({{active}} active) · {{outputs}} outputs", "menuAria": "Current debate: {{motion}}", "open": "Open debate", "useOneOff": "Use one-off mode", diff --git a/src/locales/es.json b/src/locales/es.json index dca0c6b4b..068f2add1 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -254,7 +254,10 @@ "autoSavedFolder": "Guardado automáticamente", "sourceSaveFailed": "No se pudo guardar la fuente", "sourceSaveFailedDesc": "No se pudo guardar “{{title}}” en tu biblioteca.", - "sourceAssessmentSaveFailed": "No se pudo guardar la clasificación de fuentes." + "sourceAssessmentSaveFailed": "No se pudo guardar la clasificación de fuentes.", + "sourcesAutoSaved_one": "Se añadió {{count}} fuente a tu biblioteca", + "sourcesAutoSaved_other": "Se añadieron {{count}} fuentes a tu biblioteca", + "sourcesAutoSavedDesc": "Las encontró la investigación. Están en la carpeta Guardado automáticamente, y las activadas fundamentan tus próximas solicitudes." }, "generatedArtifacts": { "brainstormTitle": "Lluvia de ideas", @@ -1612,7 +1615,24 @@ "caseDeleted": "Se eliminó el caso guardado.", "deleteCaseDesc": "Esto eliminará el caso guardado de tu historial.", "deleteCaseTitle": "¿Eliminar el caso guardado?", - "deleteFailed": "No se pudo eliminar el caso guardado." + "deleteFailed": "No se pudo eliminar el caso guardado.", + "tabCrossfire": "Interrogatorio", + "crossfireTitle": "Rondas de interrogatorio", + "emptyCrossfire": "Aún no hay rondas de interrogatorio", + "emptyCrossfireDesc": "Termina una ronda de interrogatorio para revisar aquí su transcripción y su análisis.", + "startCrossfire": "Empezar interrogatorio", + "crossfireTurns_one": "{{count}} turno", + "crossfireTurns_other": "{{count}} turnos", + "open": "Abrir", + "crossfireRound": "Ronda de interrogatorio", + "deleteCrossfireAria": "Eliminar ronda de interrogatorio: {{title}}", + "deleteCrossfireTitle": "¿Eliminar la ronda de interrogatorio?", + "deleteCrossfireDesc": "Esto elimina la transcripción y el análisis de tu historial.", + "crossfireDeleted": "Ronda de interrogatorio eliminada.", + "crossfireDeleteFailed": "No se pudo eliminar la ronda de interrogatorio.", + "crossfireUnreadable": "Esta ronda se guardó en un formato antiguo y no se puede mostrar.", + "crossfireNoDebrief": "Esta ronda no tiene análisis.", + "openCrossfireAria": "Abrir ronda de interrogatorio: {{title}}" }, "libraryPage": { "title": "Biblioteca de fuentes", @@ -1713,7 +1733,16 @@ "mergeFolderTitle": "¿Fusionar con {{name}}?", "mergeFolders": "Fusionar carpetas", "mergeFailed": "No se pudieron fusionar las carpetas. Inténtalo de nuevo.", - "openOrigin": "Abrir {{title}} en una pestaña nueva" + "openOrigin": "Abrir {{title}} en una pestaña nueva", + "savingSource": "Guardando…", + "offlineTitle": "Cambio no guardado", + "offlineNotSaved": "Estás sin conexión. Vuelve a conectarte e inténtalo de nuevo.", + "toggleFailed": "No se pudo cambiar esta fuente", + "toggleFailedDesc": "Volvió a como estaba. Inténtalo de nuevo.", + "sourceSaved": "{{title}} se guardó en la biblioteca", + "duplicateUrlDesc": "Esta página ya está en la biblioteca de este debate.", + "loadingSources": "Cargando fuentes…", + "folderUpdateFailed": "No se pudo cambiar esta carpeta" }, "autoSavedSources": { "undo": "Deshacer fuentes autoguardadas", @@ -2268,7 +2297,7 @@ "deleted": "Debate eliminado", "deleteFailed": "No se pudo eliminar el debate", "lastActivity": "Última actividad {{when}}", - "counts": "{{sources}} fuentes · {{outputs}} resultados", + "counts": "{{sources}} fuentes ({{active}} activas) · {{outputs}} resultados", "menuAria": "Debate actual: {{motion}}", "open": "Abrir debate", "useOneOff": "Usar modo puntual", diff --git a/src/locales/hu.json b/src/locales/hu.json index ccf46a2df..1c6d1ff0a 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -254,7 +254,10 @@ "autoSavedFolder": "Automatikusan mentett", "sourceSaveFailed": "A forrás mentése sikertelen", "sourceSaveFailedDesc": "Nem sikerült a(z) „{{title}}” forrást a könyvtárba menteni.", - "sourceAssessmentSaveFailed": "A forrásrangsor mentése nem sikerült." + "sourceAssessmentSaveFailed": "A forrásrangsor mentése nem sikerült.", + "sourcesAutoSaved_one": "{{count}} forrás került a könyvtáradba", + "sourcesAutoSaved_other": "{{count}} forrás került a könyvtáradba", + "sourcesAutoSavedDesc": "A kutatás találta őket. Az Automatikusan mentett mappában vannak, és a bekapcsoltak alapozzák a következő kéréseidet." }, "generatedArtifacts": { "brainstormTitle": "Ötletelés", @@ -1612,7 +1615,24 @@ "caseDeleted": "A mentett eset törölve.", "deleteCaseDesc": "Ezzel eltávolítod a mentett esetet az előzményeidből.", "deleteCaseTitle": "Törlöd a mentett esetet?", - "deleteFailed": "Nem sikerült törölni a mentett esetet." + "deleteFailed": "Nem sikerült törölni a mentett esetet.", + "tabCrossfire": "Keresztkérdezés", + "crossfireTitle": "Keresztkérdezési körök", + "emptyCrossfire": "Még nincs keresztkérdezési kör", + "emptyCrossfireDesc": "Fejezz be egy keresztkérdezési kört, és itt visszanézheted az átiratát és az értékelését.", + "startCrossfire": "Keresztkérdezés indítása", + "crossfireTurns_one": "{{count}} kör", + "crossfireTurns_other": "{{count}} kör", + "open": "Megnyitás", + "crossfireRound": "Keresztkérdezési kör", + "deleteCrossfireAria": "Keresztkérdezési kör törlése: {{title}}", + "deleteCrossfireTitle": "Törlöd a keresztkérdezési kört?", + "deleteCrossfireDesc": "Ez eltávolítja az átiratot és az értékelést az előzményeidből.", + "crossfireDeleted": "Keresztkérdezési kör törölve.", + "crossfireDeleteFailed": "Nem sikerült törölni a keresztkérdezési kört.", + "crossfireUnreadable": "Ezt a kört régebbi formátumban mentették, ezért nem jeleníthető meg.", + "crossfireNoDebrief": "Ehhez a körhöz nincs értékelés.", + "openCrossfireAria": "Keresztkérdezési kör megnyitása: {{title}}" }, "libraryPage": { "title": "Forráskönyvtár", @@ -1713,7 +1733,16 @@ "mergeFolderTitle": "Összevonod ezzel: {{name}}?", "mergeFolders": "Mappák összevonása", "mergeFailed": "Nem sikerült összevonni a mappákat. Próbáld újra.", - "openOrigin": "{{title}} megnyitása új lapon" + "openOrigin": "{{title}} megnyitása új lapon", + "savingSource": "Mentés…", + "offlineTitle": "A változás nem mentődött", + "offlineNotSaved": "Nincs internetkapcsolat. Csatlakozz újra, és próbáld meg ismét.", + "toggleFailed": "Nem sikerült módosítani ezt a forrást", + "toggleFailedDesc": "Visszaállt a korábbi állapotra. Próbáld újra.", + "sourceSaved": "A(z) {{title}} bekerült a könyvtárba", + "duplicateUrlDesc": "Ez az oldal már szerepel ennek a vitának a könyvtárában.", + "loadingSources": "Források betöltése…", + "folderUpdateFailed": "Nem sikerült módosítani ezt a mappát" }, "autoSavedSources": { "undo": "Automatikus források visszavonása", @@ -2268,7 +2297,7 @@ "deleted": "Vita törölve", "deleteFailed": "Nem sikerült törölni a vitát", "lastActivity": "Utolsó tevékenység: {{when}}", - "counts": "{{sources}} forrás · {{outputs}} eredmény", + "counts": "{{sources}} forrás ({{active}} aktív) · {{outputs}} eredmény", "menuAria": "Aktuális vita: {{motion}}", "open": "Vita megnyitása", "useOneOff": "Egyszeri mód használata", diff --git a/src/pages/CurrentDebatePage.tsx b/src/pages/CurrentDebatePage.tsx index 6ab391c4e..190ff3e80 100644 --- a/src/pages/CurrentDebatePage.tsx +++ b/src/pages/CurrentDebatePage.tsx @@ -9,7 +9,7 @@ import { EmptyState } from '@/components/ui/empty-state'; import { PageHeader } from '@/components/layout/PageHeader'; import { PRACTICE_TOOLS, PREPARE_TOOLS, ToolLink } from '@/components/features/tools/tool-links'; import { DebateForm } from '@/components/features/debate/DebateForm'; -import { sideLabelKey } from '@/components/features/debate/CurrentDebateStrip'; +import { sideLabelKey, useDebateCounts } from '@/components/features/debate/CurrentDebateStrip'; import { useAllDebateItems, useOpenDebateItem, type DebateItem } from '@/components/features/debate/debate-items'; import { useToast } from '@/hooks/use-toast'; import { formatEuropeanDateTime, formatRelativeDate } from '@/lib/date-format'; @@ -76,6 +76,7 @@ export default function CurrentDebatePage() { const updateDebate = useDebateStore((state) => state.updateDebate); const sources = useSessionStore((state) => state.sourceLibrary); const allItems = useAllDebateItems(); + const sourceCounts = useDebateCounts(debate?.id); const [editing, setEditing] = useState(false); if (!debate) { @@ -108,7 +109,7 @@ export default function CurrentDebatePage() { meta={<> {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} {' · '} - {t('debate.counts', { sources: debateSources.length, outputs: items.length, defaultValue: '{{sources}} sources · {{outputs}} outputs' })} + {t('debate.counts', { sources: sourceCounts.sources, active: sourceCounts.activeSources, outputs: items.length, defaultValue: '{{sources}} sources ({{active}} active) · {{outputs}} outputs' })} </>} actions={<> <Button variant="ghost" size="sm" onClick={() => setEditing((value) => !value)}> diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 673641bb7..c99e03808 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -4,7 +4,7 @@ import { useSessionStore } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button, buttonVariants } from '@/components/ui/button'; -import { Trash2, Eye, MessageCircle, FileText, Zap, Brain, BookOpen, Dumbbell } from 'lucide-react'; +import { Trash2, Eye, MessageCircle, FileText, Zap, Brain, BookOpen, Dumbbell, Flame, Loader2 } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; import { formatEuropeanDateTime } from '@/lib/date-format'; @@ -25,16 +25,20 @@ import { useDebateStore } from '@/lib/stores/debate-store'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { UnsortedWork } from '@/components/features/debate/UnsortedWork'; import { PageHeader } from '@/components/layout/PageHeader'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { parseSavedCrossfire, SavedCrossfireView } from '@/components/features/crossfire/SavedCrossfireView'; /** History scope value for work that belongs to no debate. */ const OUTSIDE_DEBATES = 'outside'; -const HISTORY_TABS = ['cases', 'chats', 'attacks', 'brainstorms', 'factoids', 'sparring'] as const; +const HISTORY_TABS = ['cases', 'chats', 'attacks', 'brainstorms', 'factoids', 'sparring', 'crossfire'] as const; type HistoryTab = (typeof HISTORY_TABS)[number]; const isHistoryTab = (value: string | null): value is HistoryTab => HISTORY_TABS.includes(value as HistoryTab); // Lists scroll with the page on phones; wide screens get a bounded inner list. const LIST_CLASS = 'space-y-4 lg:max-h-[500px] lg:overflow-y-auto lg:pr-4'; +// Phones wrap the tabs onto more lines instead of a sideways scroll, each one a 44px target. +const TAB_CLASS = 'min-h-11 gap-2 sm:min-h-0'; export default function HistoryPage() { const { t } = useTranslation(); @@ -53,6 +57,7 @@ export default function HistoryPage() { const savedAttacks = inScope(store.savedAttacks); const savedBrainstorms = inScope(store.savedBrainstorms); const savedFactoids = inScope(store.savedFactoids); + const savedCrossfires = inScope(store.savedCrossfires); const sparringSessions = inScope(useSparringStore((state) => state.savedSessions)); const hydrateSparringSessions = useSparringStore((state) => state.hydrateSavedSessions); const loadSparringSession = useSparringStore((state) => state.loadSavedSession); @@ -71,6 +76,11 @@ export default function HistoryPage() { }; const [caseToDelete, setCaseToDelete] = useState<{ id: string; title: string } | null>(null); const [sparringSessionToDelete, setSparringSessionToDelete] = useState<{ id: string; motion: string } | null>(null); + const [openCrossfireId, setOpenCrossfireId] = useState<string | null>(null); + const [crossfireToDelete, setCrossfireToDelete] = useState<{ id: string; title: string } | null>(null); + const [deletingCrossfire, setDeletingCrossfire] = useState(false); + const [crossfireDeleteError, setCrossfireDeleteError] = useState(false); + const openCrossfire = openCrossfireId ? store.savedCrossfires.find((item) => item.id === openCrossfireId) ?? null : null; useEffect(() => { void hydrateSparringSessions(); @@ -130,6 +140,22 @@ export default function HistoryPage() { setSparringSessionToDelete(null); }; + // The dialog stays open while the delete runs and closes only once it lands. + const confirmDeleteCrossfire = () => { + if (!crossfireToDelete || deletingCrossfire) return; + const { id } = crossfireToDelete; + setDeletingCrossfire(true); + setCrossfireDeleteError(false); + store.deleteRecordedResult('crossfire', id) + .then(() => { + setCrossfireToDelete(null); + setOpenCrossfireId((open) => open === id ? null : open); + toast({ title: t('common.deleted', 'Deleted'), description: t('historyPage.crossfireDeleted', 'Crossfire round deleted.') }); + }) + .catch(() => setCrossfireDeleteError(true)) + .finally(() => setDeletingCrossfire(false)); + }; + return ( <div className="container py-8 px-4"> <div className="max-w-5xl mx-auto"> @@ -152,13 +178,14 @@ export default function HistoryPage() { {scope === OUTSIDE_DEBATES ? <UnsortedWork /> : ( <Tabs value={tab} onValueChange={setTab} className="space-y-6"> - <TabsList className="w-full justify-start overflow-x-auto"> - <TabsTrigger value="cases" className="gap-2"><FileText className="h-4 w-4" /> {t('historyPage.tabCases')}</TabsTrigger> - <TabsTrigger value="chats" className="gap-2"><MessageCircle className="h-4 w-4" /> {t('historyPage.tabChats')}</TabsTrigger> - <TabsTrigger value="attacks" className="gap-2"><Zap className="h-4 w-4" /> {t('historyPage.tabAttacks')}</TabsTrigger> - <TabsTrigger value="brainstorms" className="gap-2"><Brain className="h-4 w-4" /> {t('historyPage.tabBrainstorms')}</TabsTrigger> - <TabsTrigger value="factoids" className="gap-2"><BookOpen className="h-4 w-4" /> {t('historyPage.tabFactoids')}</TabsTrigger> - <TabsTrigger value="sparring" className="gap-2"><Dumbbell className="h-4 w-4" /> {t('historyPage.tabSparring')}</TabsTrigger> + <TabsList className="h-auto w-full flex-wrap justify-start"> + <TabsTrigger value="cases" className={TAB_CLASS}><FileText className="h-4 w-4" /> {t('historyPage.tabCases')}</TabsTrigger> + <TabsTrigger value="chats" className={TAB_CLASS}><MessageCircle className="h-4 w-4" /> {t('historyPage.tabChats')}</TabsTrigger> + <TabsTrigger value="attacks" className={TAB_CLASS}><Zap className="h-4 w-4" /> {t('historyPage.tabAttacks')}</TabsTrigger> + <TabsTrigger value="brainstorms" className={TAB_CLASS}><Brain className="h-4 w-4" /> {t('historyPage.tabBrainstorms')}</TabsTrigger> + <TabsTrigger value="factoids" className={TAB_CLASS}><BookOpen className="h-4 w-4" /> {t('historyPage.tabFactoids')}</TabsTrigger> + <TabsTrigger value="sparring" className={TAB_CLASS}><Dumbbell className="h-4 w-4" /> {t('historyPage.tabSparring')}</TabsTrigger> + <TabsTrigger value="crossfire" className={TAB_CLASS}><Flame className="h-4 w-4" /> {t('historyPage.tabCrossfire', 'Crossfire')}</TabsTrigger> </TabsList> <TabsContent value="cases"> @@ -365,8 +392,112 @@ export default function HistoryPage() { </CardContent> </Card> </TabsContent> + <TabsContent value="crossfire"> + <Card> + <CardHeader> + <CardTitle>{t('historyPage.crossfireTitle', 'Crossfire rounds')}</CardTitle> + </CardHeader> + <CardContent> + {savedCrossfires.length === 0 ? ( + <EmptyState + icon={Flame} + title={t('historyPage.emptyCrossfire', 'No Crossfire rounds yet')} + description={t('historyPage.emptyCrossfireDesc', 'Finish a Crossfire round to review its transcript and debrief here.')} + actionLabel={t('historyPage.startCrossfire', 'Start Crossfire')} + onAction={() => navigate('/app/crossfire')} + /> + ) : ( + <div className={LIST_CLASS}> + {savedCrossfires.map((item) => { + const turns = parseSavedCrossfire(item.data)?.turns.length; + return ( + <div key={item.id} data-testid="history-crossfire-row" className="flex items-center justify-between gap-4 rounded-lg border bg-card p-4 transition-colors hover:bg-accent/5"> + <div className="min-w-0 space-y-1"> + <h4 className="line-clamp-2 break-words font-semibold">{item.title}</h4> + <p className="text-xs text-muted-foreground"> + {turns !== undefined && <>{t('historyPage.crossfireTurns', { count: turns })} • </>} + {formatEuropeanDateTime(item.createdAt)} + </p> + </div> + <div className="flex shrink-0 gap-2"> + <Button + variant="outline" + size="sm" + className="pointer-coarse:h-11" + onClick={() => setOpenCrossfireId(item.id)} + aria-label={t('historyPage.openCrossfireAria', { title: item.title, defaultValue: 'Open Crossfire round: {{title}}' })} + > + <Eye className="mr-2 h-4 w-4" /> {t('historyPage.open', 'Open')} + </Button> + <Button + variant="ghost" + size="icon" + className="text-destructive pointer-coarse:h-11 pointer-coarse:w-11" + onClick={() => setCrossfireToDelete({ id: item.id, title: item.title })} + aria-label={t('historyPage.deleteCrossfireAria', { title: item.title, defaultValue: 'Delete Crossfire round: {{title}}' })} + > + <Trash2 className="h-4 w-4" /> + </Button> + </div> + </div> + ); + })} + </div> + )} + </CardContent> + </Card> + </TabsContent> </Tabs> )} + <Sheet open={Boolean(openCrossfire)} onOpenChange={(open) => !open && setOpenCrossfireId(null)}> + <SheetContent side="right" className="flex w-full flex-col overflow-hidden p-0 sm:max-w-2xl"> + {openCrossfire && ( + <> + <SheetHeader className="border-b px-5 py-4 text-left"> + <SheetTitle className="pr-8 break-words">{openCrossfire.title}</SheetTitle> + <SheetDescription>{t('historyPage.crossfireRound', 'Crossfire round')} • {formatEuropeanDateTime(openCrossfire.createdAt)}</SheetDescription> + </SheetHeader> + <div className="flex-1 overflow-y-auto px-5 py-5" data-testid="saved-crossfire"> + <SavedCrossfireView output={openCrossfire.data} /> + </div> + </> + )} + </SheetContent> + </Sheet> + <AlertDialog + open={Boolean(crossfireToDelete)} + onOpenChange={(open) => { + if (open || deletingCrossfire) return; + setCrossfireToDelete(null); + setCrossfireDeleteError(false); + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{t('historyPage.deleteCrossfireTitle', 'Delete Crossfire round?')}</AlertDialogTitle> + <AlertDialogDescription> + {t('historyPage.deleteCrossfireDesc', 'This removes the transcript and debrief from your history.')} + </AlertDialogDescription> + </AlertDialogHeader> + {crossfireDeleteError && ( + <p role="alert" className="text-sm text-destructive">{t('historyPage.crossfireDeleteFailed', 'Could not delete the Crossfire round.')}</p> + )} + <AlertDialogFooter> + <AlertDialogCancel disabled={deletingCrossfire}>{t('common.cancel', 'Cancel')}</AlertDialogCancel> + <AlertDialogAction + className={buttonVariants({ variant: 'destructive' })} + disabled={deletingCrossfire} + onClick={(event) => { + event.preventDefault(); + confirmDeleteCrossfire(); + }} + > + {deletingCrossfire && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />} + {t('common.delete', 'Delete')} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> <AlertDialog open={Boolean(caseToDelete)} onOpenChange={(open) => !open && setCaseToDelete(null)}> <AlertDialogContent> <AlertDialogHeader> diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index eae56ef3e..d5db1dc47 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -52,7 +52,7 @@ function CurrentDebateSummary({ t }: { t: TFunction }) { <p className="text-sm text-muted-foreground"> {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} {' · '} - {t('debate.counts', { sources: counts.sources, outputs: counts.outputs, defaultValue: '{{sources}} sources · {{outputs}} outputs' })} + {t('debate.counts', { sources: counts.sources, active: counts.activeSources, outputs: counts.outputs, defaultValue: '{{sources}} sources ({{active}} active) · {{outputs}} outputs' })} </p> {oneOff && <p className="text-sm font-medium text-primary">{t('debate.oneOffActive', 'One-off mode is on')}</p>} </div> diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index b586b7c0a..aa6add7e0 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { DebateRequiredError, useSessionStore, type Source } from '@/lib/stores/session-store'; +import { DebateRequiredError, DuplicateSourceError, isPendingSource, useSessionStore, type Source } from '@/lib/stores/session-store'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -12,6 +12,9 @@ import { Switch } from '@/components/ui/switch'; import { useToast } from '@/hooks/use-toast'; import { formatEuropeanDate } from '@/lib/date-format'; import { importSourceFromUrl } from '@/lib/source-import'; +import { findSavedPage } from '@/lib/source-url'; +import { validateUrl } from '@/lib/validation/url'; +import { browserIsOffline, isBrowserNetworkFailure } from '@/lib/ai/client'; import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { importSourceFile, SourceFileImportError } from '@/lib/source-file-import'; import { @@ -62,53 +65,54 @@ function SourceRow({ : sourceFolder; + const pending = isPendingSource(source); + // A row still saving has no server id yet, so it cannot be opened, changed or deleted. + const open = () => { + if (!pending) onOpen(source); + }; + return ( <div className="px-1 pb-2"> {/* The row is clickable for pointers; the title button is the keyboard and screen-reader target. */} <div - onClick={() => onOpen(source)} - className={`flex min-h-[84px] cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors motion-list-item hover:border-primary/40 hover:bg-accent/40 ${source.isActive ? 'bg-card' : 'bg-muted/30 opacity-60'}`} + onClick={open} + aria-busy={pending || undefined} + data-testid="library-source-row" + className={`flex min-h-[84px] flex-col gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors motion-list-item sm:flex-row sm:items-center sm:justify-between sm:gap-3 ${pending ? 'cursor-progress' : 'cursor-pointer hover:border-primary/40 hover:bg-accent/40'} ${source.isActive ? 'bg-card' : 'bg-muted/30 opacity-60'}`} > - <div className="flex min-w-0 flex-1 items-center gap-3"> + <div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center"> <div className={`shrink-0 rounded-md p-1.5 ${source.isActive ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}> - {source.type === 'url' ? <Globe className="h-4 w-4" /> : <FileText className="h-4 w-4" />} + {pending + ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> + : source.type === 'url' ? <Globe className="h-4 w-4" /> : <FileText className="h-4 w-4" />} </div> - <div className="min-w-0"> - {/* On a phone the title gets two lines of its own; one truncated line left a few letters. */} - <div className="flex items-start gap-1.5 sm:items-center"> + <div className="min-w-0 flex-1"> + {/* On a phone the controls sit on their own line, so the title has the row's width and two lines. */} + <div className="flex min-w-0 items-start gap-1.5 sm:items-center"> <button type="button" + disabled={pending} onClick={(event) => { event.stopPropagation(); - onOpen(source); + open(); }} - className="line-clamp-2 min-w-0 break-words rounded-sm text-left text-sm font-medium text-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:line-clamp-1" + className="line-clamp-2 min-w-0 break-words rounded-sm text-left text-sm font-medium text-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-progress sm:line-clamp-1" > {source.title} </button> - {source.origin?.startsWith('http') && ( - <a - href={source.origin} - target="_blank" - rel="noopener noreferrer" - onClick={(event) => event.stopPropagation()} - className="mt-0.5 shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-primary sm:mt-0" - aria-label={t('libraryPage.openOrigin', { title: source.title, defaultValue: 'Open {{title}} in a new tab' })} - > - <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" /> - </a> - )} {/* The row icon already shows the type; on a phone the title needs the room. */} <Badge variant="outline" className="hidden shrink-0 py-0 sm:inline-flex"> {t(`libraryPage.${source.type}Source`, source.type.toUpperCase())} </Badge> </div> - <p className="mt-0.5 truncate text-xs text-muted-foreground">{formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel}</p> + <p className="mt-0.5 line-clamp-2 break-all text-xs text-muted-foreground sm:line-clamp-1"> + {pending ? t('libraryPage.savingSource', 'Saving…') : formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel} + </p> {/* Badges wrap on narrow rows; the text preview only fits beside them on wider screens. */} <div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5"> <Badge variant="secondary" - className="max-w-[200px] shrink-0 truncate py-0 font-medium" + className="max-w-full shrink-0 truncate py-0 font-medium sm:max-w-[200px]" title={`${folderLabel}: ${sourceFolderLabel}`} > <Folder className="mr-1 h-3 w-3 shrink-0" /> @@ -133,11 +137,24 @@ function SourceRow({ </div> </div> </div> - <div className="flex shrink-0 items-center gap-1"> + <div className="-mr-1 flex shrink-0 items-center justify-end gap-1 sm:mr-0"> + {source.origin?.startsWith('http') && ( + <a + href={source.origin} + target="_blank" + rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} + className={`${buttonVariants({ variant: 'ghost', size: 'icon' })} h-9 w-9 text-muted-foreground hover:text-primary pointer-coarse:h-11 pointer-coarse:w-11`} + aria-label={t('libraryPage.openOrigin', { title: source.title, defaultValue: 'Open {{title}} in a new tab' })} + > + <ExternalLink className="h-4 w-4" aria-hidden="true" /> + </a> + )} <Button variant="ghost" size="icon" className="h-9 w-9 text-destructive pointer-coarse:h-11 pointer-coarse:w-11" + disabled={pending} onClick={(event) => { event.stopPropagation(); onRemove(source); @@ -151,6 +168,7 @@ function SourceRow({ <Switch className="relative pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5" checked={source.isActive} + disabled={pending} onCheckedChange={() => onToggle(source.id)} aria-label={t('libraryPage.toggleSourceLabel', { title: source.title })} /> @@ -181,6 +199,7 @@ export default function LibraryPage() { toggleSource, clearLibrary, } = useSessionStore(); + const serverHydration = useSessionStore((state) => state.serverHydration); const { toast } = useToast(); // The Library shows only the Current Debate's sources; switching debates switches the Library. const currentDebate = useCurrentDebate(); @@ -188,6 +207,7 @@ export default function LibraryPage() { const sourceLibrary = currentDebate ? allSources.filter((source) => source.debateId === currentDebate.id) : []; const [isAdding, setIsAdding] = useState(false); + const [isSavingSource, setIsSavingSource] = useState(false); const [isClearing, setIsClearing] = useState(false); const [isImportingFile, setIsImportingFile] = useState(false); const [addErrors, setAddErrors] = useState<{ title?: string; content?: string }>({}); @@ -199,6 +219,13 @@ export default function LibraryPage() { const addContentRef = useRef<HTMLTextAreaElement>(null); const draftTitleRef = useRef<HTMLInputElement>(null); const draftContentRef = useRef<HTMLTextAreaElement>(null); + const draftLoadedFor = useRef<string | null>(null); + // Fields the student edited in the sheet, each with an edit count. Save sends + // only these; the others follow the stored source while the sheet is open. + const touchedDraftFields = useRef(new Map<keyof typeof sourceDraft, number>()); + // Counts sheet openings, so a save that lands after a reopen leaves the new edits alone. + const draftOpening = useRef(0); + const [isSavingDraft, setIsSavingDraft] = useState(false); const [newSource, setNewSource] = useState({ title: '', content: '', @@ -241,24 +268,46 @@ export default function LibraryPage() { content: content.trim() ? undefined : t('libraryPage.contentRequired', 'Add the source text.'), }); + const showSaveError = (error: unknown) => { + if (error instanceof DuplicateSourceError) { + toast({ + title: t('libraryPage.duplicateSource', 'Source already in library'), + description: t('libraryPage.duplicateSourceDesc', 'This URL or file is already saved.'), + variant: 'destructive', + }); + return; + } + toast({ + title: t('libraryPage.saveFailed', 'Could not save this source.'), + description: error instanceof DebateRequiredError + ? t('debate.sourceNeedsDebate', 'Start or select a debate before saving sources.') + : browserIsOffline() || isBrowserNetworkFailure(error) + ? t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.') + : undefined, + variant: 'destructive', + }); + }; + const showSaved = (title: string) => { + toast({ title: t('libraryPage.sourceSaved', { title, defaultValue: 'Saved {{title}} to the Library' }) }); + }; + const handleAdd = async () => { + if (isSavingSource) return; const errors = requiredErrors(newSource.title, newSource.content); setAddErrors(errors); if (errors.title) return addTitleRef.current?.focus(); if (errors.content) return addContentRef.current?.focus(); // The Library always shows the Current Debate, even while tools run one-off. + setIsSavingSource(true); try { await addSource({ ...newSource, debateId: currentDebate?.id ?? null }); } catch (error) { - toast({ - title: t('libraryPage.saveFailed', 'Could not save this source.'), - description: error instanceof DebateRequiredError - ? t('debate.sourceNeedsDebate', 'Start or select a debate before saving sources.') - : undefined, - variant: 'destructive', - }); + showSaveError(error); return; + } finally { + setIsSavingSource(false); } + showSaved(newSource.title.trim()); setNewSource({ title: '', content: '', type: 'text', origin: '', folder: newSource.folder }); setIsAdding(false); }; @@ -270,6 +319,16 @@ export default function LibraryPage() { return; } + // Warn before fetching a page this debate already has. + if (findSavedPage(sourceLibrary, currentDebate?.id, validateUrl(trimmedUrl))) { + toast({ + title: t('libraryPage.duplicateSource', 'Source already in library'), + description: t('libraryPage.duplicateUrlDesc', 'This page is already in this debate\'s Library.'), + variant: 'destructive', + }); + return; + } + setIsImportingUrl(true); try { const imported = await importSourceFromUrl(trimmedUrl); @@ -325,29 +384,57 @@ export default function LibraryPage() { }; const handleSavePreview = async () => { - if (!importPreview?.title || !importPreview.content) return; + if (!importPreview?.title || !importPreview.content || isSavingSource) return; const trimmedOrigin = importPreview.origin?.trim(); - if (trimmedOrigin && sourceLibrary.some(existing => existing.origin?.trim().toLowerCase() === trimmedOrigin.toLowerCase())) { - toast({ - title: t('libraryPage.duplicateSource', 'Source already in library'), - description: t('libraryPage.duplicateSourceDesc', 'This URL or file is already saved.'), - variant: 'destructive', + // Web pages are matched by their normalized URL in the store; files by name here. + if (importPreview.type === 'file' && trimmedOrigin && sourceLibrary.some(existing => existing.origin?.trim().toLowerCase() === trimmedOrigin.toLowerCase())) { + showSaveError(new DuplicateSourceError('')); + return; + } + setIsSavingSource(true); + try { + await addSource({ + title: importPreview.title.trim(), + content: importPreview.content.trim(), + type: importPreview.type, + origin: importPreview.origin, + folder: newSource.folder, + debateId: currentDebate?.id ?? null, }); + } catch (error) { + showSaveError(error); return; + } finally { + setIsSavingSource(false); } - await addSource({ - title: importPreview.title.trim(), - content: importPreview.content.trim(), - type: importPreview.type, - origin: importPreview.origin, - folder: newSource.folder, - debateId: currentDebate?.id ?? null, - }); + showSaved(importPreview.title.trim()); setImportPreview(null); setImportUrl(''); setIsAdding(false); }; + // Turning a source on or off changes what grounds the AI, so a change that + // does not reach the server must never look saved. + const handleToggleSource = (id: string) => { + if (browserIsOffline()) { + toast({ + title: t('libraryPage.offlineTitle', 'Change not saved'), + description: t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.'), + variant: 'destructive', + }); + return; + } + void toggleSource(id).catch((error: unknown) => { + toast({ + title: t('libraryPage.toggleFailed', 'Could not change this source'), + description: isBrowserNetworkFailure(error) + ? t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.') + : t('libraryPage.toggleFailedDesc', 'It is back to how it was. Try again.'), + variant: 'destructive', + }); + }); + }; + const handleClearLibrary = async () => { if (isClearing) return; setIsClearing(true); @@ -363,6 +450,8 @@ export default function LibraryPage() { }; const activeSourceCount = sourceLibrary.filter(source => source.isActive).length; + // Until the server list arrives, an empty Library may just not be loaded yet. + const firstLoadPending = serverHydration === 'pending' && sourceLibrary.length === 0; const hasActiveSources = activeSourceCount > 0; // Folder names are account-wide; hide the ones only other debates use, but keep new empty ones. const otherDebateFolders = new Set(allSources.filter((source) => source.debateId !== currentDebate?.id).map((source) => source.folder || 'General')); @@ -421,15 +510,38 @@ export default function LibraryPage() { const selectedSource = selectedSourceId ? sourceLibrary.find((source) => source.id === selectedSourceId) || null : null; useEffect(() => { - if (!selectedSource) return; - setDraftErrors({}); - setSourceDraft({ + // Opening a source loads every field. While it stays open, fields the + // student has not edited follow the stored source (a rolled-back switch, + // a research refresh), and edited ones keep what was typed, even when a + // save fails. + if (!selectedSource) { + draftLoadedFor.current = null; + touchedDraftFields.current.clear(); + return; + } + const stored = { title: selectedSource.title, content: selectedSource.content, type: selectedSource.type, origin: selectedSource.origin || '', folder: selectedSource.folder || 'General', isActive: selectedSource.isActive, + }; + if (draftLoadedFor.current !== selectedSource.id) { + draftLoadedFor.current = selectedSource.id; + draftOpening.current += 1; + touchedDraftFields.current.clear(); + setDraftErrors({}); + setSourceDraft(stored); + return; + } + const touched = touchedDraftFields.current; + setSourceDraft((draft) => { + const next = { ...draft }; + for (const key of Object.keys(stored) as Array<keyof typeof stored>) { + if (!touched.has(key)) (next as Record<string, unknown>)[key] = stored[key]; + } + return next; }); }, [selectedSource]); useEffect(() => { @@ -496,23 +608,56 @@ export default function LibraryPage() { }; const handleSetSelectedFolderActive = async (isActive: boolean) => { if (selectedFolder === 'all') return; - await setFolderActive(selectedFolder, isActive); + try { + await setFolderActive(selectedFolder, isActive); + } catch (error) { + toast({ + title: t('libraryPage.folderUpdateFailed', 'Could not change this folder'), + description: browserIsOffline() || isBrowserNetworkFailure(error) + ? t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.') + : t('libraryPage.toggleFailedDesc', 'It is back to how it was. Try again.'), + variant: 'destructive', + }); + } + }; + const editDraft = (patch: Partial<typeof sourceDraft>) => { + const touched = touchedDraftFields.current; + for (const key of Object.keys(patch) as Array<keyof typeof sourceDraft>) touched.set(key, (touched.get(key) ?? 0) + 1); + setSourceDraft((draft) => ({ ...draft, ...patch })); }; const handleSaveSourceDraft = async () => { - if (!selectedSource) return; + if (!selectedSource || isSavingDraft) return; const errors = requiredErrors(sourceDraft.title, sourceDraft.content); setDraftErrors(errors); if (errors.title) return draftTitleRef.current?.focus(); if (errors.content) return draftContentRef.current?.focus(); - await updateSource(selectedSource.id, { - title: sourceDraft.title, - content: sourceDraft.content, - type: sourceDraft.type, - origin: sourceDraft.origin, - folder: sourceDraft.folder, - isActive: sourceDraft.isActive, - }); + // Only what was edited here is written, so a change that reached this + // source while the sheet was open (a switch, research) is not overwritten. + const opening = draftOpening.current; + const sent = new Map(touchedDraftFields.current); + const patch = Object.fromEntries(Array.from(sent.keys(), (key) => [key, sourceDraft[key]])) as Partial<typeof sourceDraft>; + setIsSavingDraft(true); + try { + await updateSource(selectedSource.id, patch); + // A field counts as saved only if it was not edited again meanwhile and + // the sheet still shows the opening this save came from. + if (draftOpening.current === opening) { + const touched = touchedDraftFields.current; + for (const [key, edits] of sent) if (touched.get(key) === edits) touched.delete(key); + } + } catch (error) { + toast({ + title: t('libraryPage.saveFailed', 'Could not save this source.'), + description: browserIsOffline() || isBrowserNetworkFailure(error) + ? t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.') + : undefined, + variant: 'destructive', + }); + return; + } finally { + setIsSavingDraft(false); + } toast({ title: t('libraryPage.sourceUpdated', 'Source updated'), description: t('libraryPage.sourceUpdatedDesc', 'Your source changes were saved.') }); }; const handleCopySource = async () => { @@ -704,7 +849,8 @@ export default function LibraryPage() { {[importPreview.domain || importPreview.fileType || importPreview.origin, importPreview.wordCount ? `${importPreview.wordCount} ${t('libraryPage.words')}` : null].filter(Boolean).join(' • ')} </p> </div> - <Button type="button" size="sm" onClick={() => void handleSavePreview()}> + <Button type="button" size="sm" disabled={isSavingSource} onClick={() => void handleSavePreview()}> + {isSavingSource && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />} {t('libraryPage.saveImported')} </Button> </div> @@ -777,7 +923,10 @@ export default function LibraryPage() { /> {addErrors.content && <p id="new-source-content-error" role="alert" className="text-sm text-destructive">{addErrors.content}</p>} </div> - <Button onClick={() => void handleAdd()} className="w-full">{t('libraryPage.saveToLibrary')}</Button> + <Button onClick={() => void handleAdd()} className="w-full" disabled={isSavingSource}> + {isSavingSource && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />} + {t('libraryPage.saveToLibrary')} + </Button> </CardContent> </Card> )} @@ -790,10 +939,12 @@ export default function LibraryPage() { <CardTitle>{t('libraryPage.yourKBTitle')}</CardTitle> </div> <div className="flex items-center gap-2"> - <Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20"> - <Database className="h-3 w-3 mr-1" /> - {visibleSources.length} {t('libraryPage.sourcesCount')} - </Badge> + {!firstLoadPending && ( + <Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20"> + <Database className="h-3 w-3 mr-1" /> + {visibleSources.length} {t('libraryPage.sourcesCount')} + </Badge> + )} </div> </div> <div className="flex flex-col gap-3 lg:flex-row lg:items-center"> @@ -803,7 +954,7 @@ export default function LibraryPage() { className="pl-9 pr-9" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} - placeholder={t('libraryPage.searchPlaceholder', 'Search sources, folders, or content...')} + placeholder={t('libraryPage.searchSources', 'Search sources')} aria-label={t('libraryPage.searchSources', 'Search sources')} /> {searchQuery && ( @@ -819,7 +970,7 @@ export default function LibraryPage() { </Button> )} </div> - <div className="flex gap-1 overflow-x-auto pb-1" role="group" aria-label={t('libraryPage.folder', 'Folder')}> + <div className="flex flex-wrap gap-1 pb-1 lg:flex-nowrap lg:overflow-x-auto" role="group" aria-label={t('libraryPage.folder', 'Folder')}> {['all', ...folders].map((folder) => ( <Button key={folder} @@ -827,7 +978,7 @@ export default function LibraryPage() { variant={selectedFolder === folder ? 'default' : 'outline'} size="sm" aria-pressed={selectedFolder === folder} - className="shrink-0" + className="max-w-full shrink-0 truncate" onClick={() => setSelectedFolder(folder)} > {folder === 'all' ? t('libraryPage.allFolders', 'All folders') : getFolderName(folder)} @@ -905,8 +1056,13 @@ export default function LibraryPage() { </div> )} </CardHeader> - <CardContent> - {sourceLibrary.length === 0 ? ( + <CardContent className="px-3 sm:px-6"> + {firstLoadPending ? ( + <div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground" role="status"> + <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> + {t('libraryPage.loadingSources', 'Loading sources…')} + </div> + ) : sourceLibrary.length === 0 ? ( <div className="text-center py-12 border-2 border-dashed rounded-lg"> <Database className="h-12 w-12 text-muted-foreground/20 mx-auto mb-4" /> <h3 className="text-lg font-medium">{t('libraryPage.emptyTitle')}</h3> @@ -930,7 +1086,7 @@ export default function LibraryPage() { key={source.id} source={source} onRemove={handleRemoveSource} - onToggle={toggleSource} + onToggle={handleToggleSource} onOpen={(item: Source) => setSelectedSourceId(item.id)} manualEntryLabel={t('libraryPage.manualEntry')} folderLabel={t('libraryPage.folder', 'Folder')} @@ -1036,7 +1192,7 @@ export default function LibraryPage() { aria-invalid={Boolean(draftErrors.title)} aria-describedby={draftErrors.title ? 'source-detail-title-error' : undefined} onChange={(event) => { - setSourceDraft({ ...sourceDraft, title: event.target.value }); + editDraft({ title: event.target.value }); if (draftErrors.title) setDraftErrors({ ...draftErrors, title: undefined }); }} /> @@ -1045,7 +1201,7 @@ export default function LibraryPage() { <div className="grid gap-2 sm:grid-cols-2"> <div className="space-y-2"> <Label htmlFor="source-detail-folder">{t('libraryPage.folder', 'Folder')}</Label> - <Select value={sourceDraft.folder || 'General'} onValueChange={(folder) => setSourceDraft({ ...sourceDraft, folder })}> + <Select value={sourceDraft.folder || 'General'} onValueChange={(folder) => editDraft({ folder })}> <SelectTrigger id="source-detail-folder"> <SelectValue /> </SelectTrigger> @@ -1058,7 +1214,7 @@ export default function LibraryPage() { </div> <div className="space-y-2"> <Label htmlFor="source-detail-type">{t('libraryPage.typeLabel', 'Type')}</Label> - <Select value={sourceDraft.type} onValueChange={(type) => setSourceDraft({ ...sourceDraft, type: type as Source['type'] })}> + <Select value={sourceDraft.type} onValueChange={(type) => editDraft({ type: type as Source['type'] })}> <SelectTrigger id="source-detail-type"> <SelectValue /> </SelectTrigger> @@ -1076,7 +1232,7 @@ export default function LibraryPage() { id="source-detail-origin" value={sourceDraft.origin || ''} placeholder={t('libraryPage.originPlaceholder', 'URL, file name, or citation note')} - onChange={(event) => setSourceDraft({ ...sourceDraft, origin: event.target.value })} + onChange={(event) => editDraft({ origin: event.target.value })} /> </div> <div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2"> @@ -1086,7 +1242,7 @@ export default function LibraryPage() { </div> <Switch checked={sourceDraft.isActive} - onCheckedChange={(checked) => setSourceDraft({ ...sourceDraft, isActive: checked })} + onCheckedChange={(checked) => editDraft({ isActive: checked })} aria-label={t('libraryPage.useForGrounding', 'Use for AI grounding')} /> </div> @@ -1100,7 +1256,7 @@ export default function LibraryPage() { aria-invalid={Boolean(draftErrors.content)} aria-describedby={draftErrors.content ? 'source-detail-content-error' : undefined} onChange={(event) => { - setSourceDraft({ ...sourceDraft, content: event.target.value }); + editDraft({ content: event.target.value }); if (draftErrors.content) setDraftErrors({ ...draftErrors, content: undefined }); }} /> @@ -1119,8 +1275,8 @@ export default function LibraryPage() { {t('common.delete', 'Delete')} </Button> </div> - <Button type="button" onClick={() => void handleSaveSourceDraft()}> - <Save className="mr-2 h-4 w-4" /> + <Button type="button" disabled={isSavingDraft} onClick={() => void handleSaveSourceDraft()}> + {isSavingDraft ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <Save className="mr-2 h-4 w-4" />} {t('common.save', 'Save')} </Button> </SheetFooter> diff --git a/src/test/stores/library-sources.test.ts b/src/test/stores/library-sources.test.ts new file mode 100644 index 000000000..6c652f8cd --- /dev/null +++ b/src/test/stores/library-sources.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActiveAccessToken, supabase } from '@/lib/supabase/client'; +import { DuplicateSourceError, isPendingSource, useSessionStore, type Source } from '@/lib/stores/session-store'; +import { resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise<void>((done) => { resolve = done; }); + return { promise, resolve }; +} + +/** A Supabase update whose filters can chain any number of times, answering with `result()`. */ +function updateQuery(result: () => Promise<{ error: { message: string } | null }>) { + const query = { eq: () => query, in: () => query, then: (done: (value: unknown) => unknown, fail: (reason: unknown) => unknown) => result().then(done, fail) }; + return query; +} + +const SAVED_ID = '33333333-3333-4333-8333-333333333333'; +const PAGE = 'https://www.motherly.com/homework-should-be-banned/'; + +function source(patch: Partial<Source> = {}): Source { + return { + id: SAVED_ID, + title: 'Why Homework Should Be Banned | Motherly', + content: 'Text', + type: 'url', + origin: PAGE, + folder: 'Auto-saved', + debateId: TEST_DEBATE_ID, + isActive: true, + createdAt: 1, + ...patch, + }; +} + +function insertedRow(origin: string) { + return { + id: SAVED_ID, + title: 'Homework', + content: 'Text', + type: 'url', + origin, + folder: 'General', + metadata: null as null, + debate_id: TEST_DEBATE_ID, + is_active: true, + created_at: '2026-10-22T00:00:00.000Z', + }; +} + +beforeEach(() => { + setActiveAccessToken('token', 'user-1'); + seedCurrentDebate(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setActiveAccessToken(null, null); + resetDebates(); + useSessionStore.setState({ sourceLibrary: [], sourceFolders: ['General'] }); +}); + +describe('toggleSource', () => { + it('puts the switch back and rejects when the write fails', async () => { + useSessionStore.setState({ sourceLibrary: [source()] }); + const network = new TypeError('Failed to fetch'); + vi.spyOn(supabase, 'from').mockImplementation(() => ({ + update: () => ({ eq: () => ({ eq: async () => { throw network; } }) }), + }) as never); + + const toggle = useSessionStore.getState().toggleSource(SAVED_ID); + // It moves at once, before the server answers. + expect(useSessionStore.getState().sourceLibrary[0].isActive).toBe(false); + await expect(toggle).rejects.toBe(network); + expect(useSessionStore.getState().sourceLibrary[0].isActive).toBe(true); + }); + + it('puts the switch back when the server refuses the update', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: false })] }); + vi.spyOn(supabase, 'from').mockImplementation(() => ({ + update: () => ({ eq: () => ({ eq: async (): Promise<{ error: { message: string } }> => ({ error: { message: 'permission denied' } }) }) }), + }) as never); + + await expect(useSessionStore.getState().toggleSource(SAVED_ID)).rejects.toThrow(); + expect(useSessionStore.getState().sourceLibrary[0].isActive).toBe(false); + }); +}); + +describe('addSource keeps one row per web page in a debate', () => { + it('refuses a page this debate already has, before any write', async () => { + useSessionStore.setState({ sourceLibrary: [source()] }); + const from = vi.spyOn(supabase, 'from'); + + const add = useSessionStore.getState().addSource({ + title: 'Homework again', + content: 'Text', + type: 'url', + origin: 'https://motherly.com/homework-should-be-banned?utm_source=exa#top', + }); + await expect(add).rejects.toBeInstanceOf(DuplicateSourceError); + await expect(add).rejects.toMatchObject({ existingId: SAVED_ID }); + expect(from).not.toHaveBeenCalled(); + expect(useSessionStore.getState().sourceLibrary).toHaveLength(1); + }); + + it('refuses a page the server already has for this debate, and drops the shown row', async () => { + const insert = vi.fn(); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => { + if (table !== 'sources') throw new Error(`unexpected table ${table}`); + return { + select: () => ({ eq: () => ({ eq: async () => ({ data: [{ id: SAVED_ID, origin: null as string | null, metadata: { url: PAGE } }], error: null as null }) }) }), + insert, + } as never; + }); + + await expect(useSessionStore.getState().addSource({ + title: 'Homework', + content: 'Text', + type: 'url', + origin: 'http://motherly.com/homework-should-be-banned', + })).rejects.toBeInstanceOf(DuplicateSourceError); + expect(insert).not.toHaveBeenCalled(); + expect(useSessionStore.getState().sourceLibrary).toEqual([]); + }); + + it('shows the row while it saves, then swaps in the saved row', async () => { + let finishInsert!: () => void; + const inserted = new Promise<void>((resolve) => { finishInsert = resolve; }); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => { + if (table === 'source_folders') return { upsert: async (): Promise<{ error: null }> => ({ error: null }) } as never; + return { + select: () => ({ eq: () => ({ eq: async () => ({ data: [] as unknown[], error: null as null }) }) }), + insert: () => ({ select: () => ({ single: async (): Promise<{ data: ReturnType<typeof insertedRow>; error: null }> => { await inserted; return { data: insertedRow(PAGE), error: null }; } }) }), + } as never; + }); + + const add = useSessionStore.getState().addSource({ title: 'Homework', content: 'Text', type: 'url', origin: PAGE }); + await vi.waitFor(() => expect(useSessionStore.getState().sourceLibrary).toHaveLength(1)); + expect(isPendingSource(useSessionStore.getState().sourceLibrary[0])).toBe(true); + + finishInsert(); + await add; + expect(useSessionStore.getState().sourceLibrary.map((item) => item.id)).toEqual([SAVED_ID]); + }); + + it('puts back a deleted page with Undo, as a row that cannot be changed until it lands', async () => { + const landed = deferred(); + const insert = vi.fn(() => ({ select: () => ({ single: async (): Promise<{ data: ReturnType<typeof insertedRow>; error: null }> => { await landed.promise; return { data: insertedRow(PAGE), error: null }; } }) })); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => { + if (table === 'source_folders') return { upsert: async (): Promise<{ error: null }> => ({ error: null }) } as never; + return { insert } as never; + }); + + const restore = useSessionStore.getState().addSource({ id: SAVED_ID, createdAt: 1, title: 'Homework', content: 'Text', type: 'url', origin: PAGE }); + await vi.waitFor(() => expect(insert).toHaveBeenCalledOnce()); + // Deleting it again now would race the restore and bring it back afterwards. + const shown = useSessionStore.getState().sourceLibrary[0]; + expect(isPendingSource(shown)).toBe(true); + await expect(useSessionStore.getState().removeSource(shown.id)).rejects.toThrow(/still saving/); + + landed.resolve(); + await restore; + expect(useSessionStore.getState().sourceLibrary.map((item) => item.id)).toEqual([SAVED_ID]); + }); + + it('keeps one row when another action copies the row while it saves', async () => { + const landed = deferred(); + const writeOk = (): Promise<{ error: null }> => Promise.resolve({ error: null }); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => { + if (table === 'source_folders') return { upsert: writeOk, delete: () => updateQuery(writeOk) } as never; + return { + select: () => ({ eq: () => ({ eq: async () => ({ data: [] as unknown[], error: null as null }) }) }), + insert: () => ({ select: () => ({ single: async (): Promise<{ data: ReturnType<typeof insertedRow>; error: null }> => { await landed.promise; return { data: { ...insertedRow(PAGE), folder: 'Research' }, error: null }; } }) }), + update: () => updateQuery(writeOk), + } as never; + }); + + const add = useSessionStore.getState().addSource({ title: 'Homework', content: 'Text', type: 'url', origin: PAGE, folder: 'Research' }); + await vi.waitFor(() => expect(useSessionStore.getState().sourceLibrary).toHaveLength(1)); + const shown = useSessionStore.getState().sourceLibrary[0]; + // Renaming the folder rewrites every row in it, the one still saving included. + await useSessionStore.getState().renameSourceFolder('Research', 'Notes'); + expect(useSessionStore.getState().sourceLibrary[0]).not.toBe(shown); + landed.resolve(); + await add; + + expect(useSessionStore.getState().sourceLibrary.map((item) => item.id)).toEqual([SAVED_ID]); + }); +}); + +describe('toggleSource with writes in flight', () => { + function stubSources(update: () => Promise<{ error: { message: string } | null }>, staleRows: () => Promise<unknown[]> = async () => []) { + vi.spyOn(supabase, 'from').mockImplementation((table: string) => ({ + update: () => ({ eq: () => ({ eq: update }) }), + select: () => ({ + order: async () => ({ data: table === 'sources' ? await staleRows() : [], error: null as null }), + }), + }) as never); + } + + it('ends on what the server holds when two quick toggles both fail', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true })] }); + stubSources(async () => ({ error: { message: 'offline' } })); + + const off = useSessionStore.getState().toggleSource(SAVED_ID); + const on = useSessionStore.getState().toggleSource(SAVED_ID); + // The first failure changed nothing on screen once the second click replaced it, so only the second reports. + await expect(off).resolves.toBeUndefined(); + await expect(on).rejects.toThrow(); + + expect(useSessionStore.getState().sourceLibrary[0].isActive).toBe(true); + }); + + it('keeps a toggle that lands after a reload started but before it answers', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true })] }); + const read = deferred(); + stubSources(async () => ({ error: null }), async () => { await read.promise; return [{ ...insertedRow(PAGE), is_active: true }]; }); + + const hydrate = useSessionStore.getState().hydrateFromServer(); + await useSessionStore.getState().toggleSource(SAVED_ID); + read.resolve(); + await hydrate; + expect(useSessionStore.getState().sourceLibrary.map((item) => item.isActive)).toEqual([false]); + }); + + it('keeps a saved toggle when a server reload that read the old value lands', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true })] }); + const written = deferred(); + const read = deferred(); + stubSources(async () => { await written.promise; return { error: null }; }, async () => { await read.promise; return [{ + ...insertedRow(PAGE), is_active: true, + }]; }); + + // The reload starts first and reads before the write lands. + const hydrate = useSessionStore.getState().hydrateFromServer(); + const toggle = useSessionStore.getState().toggleSource(SAVED_ID); + read.resolve(); + await hydrate; + written.resolve(); + await toggle; + expect(useSessionStore.getState().sourceLibrary.map((item) => item.isActive)).toEqual([false]); + + }); +}); + +describe('other writes and the switch', () => { + + it('does not save a switch value with a metadata refresh while the switch write fails', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true })] }); + const toggleWrite = deferred(); + const payloads: Record<string, unknown>[] = []; + vi.spyOn(supabase, 'from').mockImplementation(() => ({ + update: (payload: Record<string, unknown>) => { + payloads.push(payload); + return updateQuery(async () => { + if ('is_active' in payload) { await toggleWrite.promise; return { error: { message: 'offline' } }; } + return { error: null }; + }); + }, + }) as never); + + const toggle = useSessionStore.getState().toggleSource(SAVED_ID); + await useSessionStore.getState().updateSource(SAVED_ID, { metadata: { url: PAGE, capturedVia: 'attack' } }); + toggleWrite.resolve(); + await expect(toggle).rejects.toThrow(); + + expect(payloads.filter((payload) => 'metadata' in payload)).toEqual([{ metadata: { url: PAGE, capturedVia: 'attack' } }]); + expect(useSessionStore.getState().sourceLibrary[0]).toMatchObject({ isActive: true, metadata: { capturedVia: 'attack' } }); + }); + + it('keeps a folder switched on after a toggle when a reload that started earlier lands', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true, folder: 'General' })] }); + const read = deferred(); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => ({ + update: () => updateQuery(async () => ({ error: null })), + select: () => ({ + order: async () => { + if (table !== 'sources') return { data: [] as unknown[], error: null as null }; + await read.promise; + return { data: [{ ...insertedRow(PAGE), is_active: true }], error: null as null }; + }, + }), + }) as never); + + const hydrate = useSessionStore.getState().hydrateFromServer(); + await useSessionStore.getState().toggleSource(SAVED_ID); + await useSessionStore.getState().setFolderActive('General', true); + read.resolve(); + await hydrate; + + expect(useSessionStore.getState().sourceLibrary.map((item) => item.isActive)).toEqual([true]); + }); + + it('keeps a toggle that started before a reload and lands after the reload read the old value', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true })] }); + const written = deferred(); + const read = deferred(); + vi.spyOn(supabase, 'from').mockImplementation((table: string) => ({ + update: () => updateQuery(async () => { await written.promise; return { error: null }; }), + select: () => ({ + order: async () => { + if (table !== 'sources') return { data: [] as unknown[], error: null as null }; + await read.promise; + return { data: [{ ...insertedRow(PAGE), is_active: true }], error: null as null }; + }, + }), + }) as never); + + const toggle = useSessionStore.getState().toggleSource(SAVED_ID); + const hydrate = useSessionStore.getState().hydrateFromServer(); + written.resolve(); + await toggle; + read.resolve(); + await hydrate; + + expect(useSessionStore.getState().sourceLibrary.map((item) => item.isActive)).toEqual([false]); + }); + + it('switches a folder by the rows it shows, not every row the server has in it', async () => { + useSessionStore.setState({ sourceLibrary: [source({ isActive: true, folder: 'Auto-saved' })] }); + const filters: Array<[string, unknown]> = []; + vi.spyOn(supabase, 'from').mockImplementation(() => ({ + update: () => { + const query = { + eq: (column: string, value: unknown) => { filters.push([column, value]); return query; }, + in: (column: string, value: unknown) => { filters.push([column, value]); return query; }, + then: (done: (value: unknown) => unknown) => Promise.resolve({ error: null }).then(done), + }; + return query; + }, + }) as never); + + await useSessionStore.getState().setFolderActive('Auto-saved', false); + // A row still saving into the folder must not take its value on the server alone. + expect(filters).toContainEqual(['id', [SAVED_ID]]); + expect(filters.map(([column]) => column)).not.toContain('folder'); + }); +}); + diff --git a/vite.config.ts b/vite.config.ts index 603efb172..476ce87d7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -115,6 +115,11 @@ export default defineConfig(({ mode }) => { hmr: { overlay: false, }, + // Playwright writes trace pages (.html) under .verify/ while a recipe runs; + // watching them reloaded the app in the middle of tests. + watch: { + ignored: ['**/.verify/**', '**/.ci-failures/**'], + }, proxy, allowedHosts, },