From 9ea2a51b542b8eb5eb9e98607545d7fa34444fbc Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 21:01:14 +0000 Subject: [PATCH 01/11] Fix Library and History issues from the 22 Oct QA pass (T7) LB-01: the source switch moves at once and goes back with a toast when the write fails; offline, it does not move and says the change was not saved. LB-03/LB-05: one row per normalized URL per debate, checked in memory and against the server before insert; URL import warns before fetching. LB-04: on phones the row controls get their own line, the meta line and folder chips wrap, and the open-in-new link is a 44px target. LB-07: a new source shows at once while it saves, with a toast after, and the Library shows a loading line instead of 0 sources before first load. HI-01/VS-07: History gets a Crossfire tab with open (transcript and debrief) and delete; tabs wrap with 44px targets on phones. MD-05: research says how many sources it added, and the debate summary shows the active count Ask and the other tools use. Co-Authored-By: Claude Opus 5.5 --- .../features/crossfire/SavedCrossfireView.tsx | 182 +++++++++++++++ .../features/debate/CurrentDebateMenu.tsx | 2 +- .../features/debate/CurrentDebateStrip.tsx | 9 +- src/lib/ai/sources.ts | 52 ++--- src/lib/source-url.ts | 42 ++++ src/lib/stores/session-store.ts | 184 +++++++++++---- src/locales/en.json | 35 ++- src/locales/es.json | 35 ++- src/locales/hu.json | 35 ++- src/pages/CurrentDebatePage.tsx | 5 +- src/pages/HistoryPage.tsx | 119 +++++++++- src/pages/HomePage.tsx | 2 +- src/pages/LibraryPage.tsx | 209 +++++++++++++----- src/test/stores/library-sources.test.ts | 145 ++++++++++++ 14 files changed, 886 insertions(+), 170 deletions(-) create mode 100644 src/components/features/crossfire/SavedCrossfireView.tsx create mode 100644 src/lib/source-url.ts create mode 100644 src/test/stores/library-sources.test.ts 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; + 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 ( + + {turnIds.map((turnId) => ( + + {turnId} + + ))} + + ); +} + +function DebriefSection({ title, items, emptyLabel }: { title: string; items: ReferenceItem[]; emptyLabel: string }) { + return ( +
+

{title}

+ {items.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
    + {items.map((item, index) => ( +
  • +

    {item.claim}

    + +
  • + ))} +
+ )} +
+ ); +} + +function Debrief({ debrief }: { debrief: CrossfireDebrief }) { + const { t } = useTranslation(); + const empty = t('crossfire.noGroundedFinding', 'No grounded finding in this transcript.'); + return ( +
+

{t('crossfire.debriefTitle')}

+
+ + + + + +
+

{t('crossfire.betterFollowUpQuestions')}

+ {debrief.betterFollowUpQuestions.length === 0 ? ( +

{empty}

+ ) : ( +
    + {debrief.betterFollowUpQuestions.map((item, index) => ( +
  • +

    {item.question}

    +

    {item.reason}

    + +
  • + ))} +
+ )} +
+
+
+
+ +

{t('crossfire.strongestExchange')}

+
+ {debrief.strongestExchange ? ( +
+

{debrief.strongestExchange.claim}

+ +
+ ) : ( +

{empty}

+ )} +
+
+ ); +} + +function Transcript({ turns }: { turns: CrossfireTurn[] }) { + const { t } = useTranslation(); + return ( +
+
+ +

{t('crossfire.transcript')}

+
+
+ {turns.map((turn) => ( +
+
+ {turn.id} + {turn.kind === 'question' ? t('crossfire.question') : t('crossfire.answer')} +
+

{turn.text}

+ {turn.kind === 'answer' && turn.signal ? ( +
+ {t(`crossfire.signalQuality.${turn.signal.answerQuality}`)} + {t(`crossfire.signalConcession.${turn.signal.concession}`)} + {turn.signal.followUpOpportunity > 0.70 ? {t('crossfire.signalFollowUp')} : null} +
+ ) : null} +
+ ))} +
+
+ ); +} + +/** 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 ( +

+

+ ); + } + return ( +
+ {round.debrief ? : ( +

{t('historyPage.crossfireNoDebrief', 'This round has no debrief.')}

+ )} + +
+ ); +} 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}}' })}

- {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' })}

{oneOff &&

{t('debate.oneOffActive', 'One-off mode is on')}

} diff --git a/src/components/features/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx index 5833b1a5e..51672753c 100644 --- a/src/components/features/debate/CurrentDebateStrip.tsx +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -12,16 +12,21 @@ 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); + const activeSources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId && source.isActive).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.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( + 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..930baf67f 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; updateRecordedResult: (kind: ResultKind, id: string, output: unknown) => Promise; + deleteRecordedResult: (kind: ResultKind, id: string) => Promise; attachItem: (kind: AttachKind, id: string, debateId: string, side?: DebateSide) => Promise; /** Records new Ask messages: into the loaded chat, or a new chat in `debateId`. */ syncAskChat: (debateId: string | null) => Promise; deleteSavedFlowchart: (id: string) => Promise; - /** `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 & { isActive?: boolean; id?: string; createdAt?: number }) => Promise; createSourceFolder: (folder: string) => Promise; updateSource: (id: string, patch: Partial>) => Promise; @@ -452,6 +458,21 @@ 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-'; + +/** A source shown before its row is saved; it has no server id to change yet. */ +export function isPendingSource(source: Pick): boolean { + return source.id.startsWith(PENDING_SOURCE_PREFIX); +} + function savedFromRow(row: SupabaseSavedRow): SavedItem { return { id: row.id, @@ -575,51 +596,92 @@ export const useSessionStore = create()( // 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); + } + const localItem: Source = { + ...source, folder, debateId, isActive, + id: restoredId ?? `${PENDING_SOURCE_PREFIX}${generateId()}`, + 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 !== localItem) })); - 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), - }; + 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) => ({ sourceFolders: mergeSourceFolders(state.sourceFolders, [savedSource]), - sourceLibrary: place(state.sourceLibrary, savedSource), + // A reload from the server while this saved may already list the row. + sourceLibrary: state.sourceLibrary.includes(localItem) + ? state.sourceLibrary.map((item) => item === localItem ? savedSource : item) + : state.sourceLibrary.some((item) => item.id === savedSource.id) + ? state.sourceLibrary + : place(state.sourceLibrary, savedSource), })); markDebateActivity(debateId); }, @@ -848,15 +910,22 @@ export const useSessionStore = create()( const current = get().sourceLibrary.find((source) => source.id === id); if (!current) return; 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', - ); - set((state) => ({ - sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive: nextValue } : source), + const setActive = (isActive: boolean) => set((state) => ({ + sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive } : source), })); + // The switch moves at once; a failed write puts it back and the caller says why. + setActive(nextValue); + try { + 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', + ); + } catch (error) { + setActive(current.isActive); + throw error; + } }, clearLibrary: async () => { // The Library belongs to the Current Debate; other debates keep their sources. @@ -1122,6 +1191,19 @@ export const useSessionStore = create()( }) as Partial); }, + 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[]).filter((item) => item.id !== id), + }) as Partial); + }, + attachItem: async (kind, id, debateId, side) => { const { error } = await supabase.rpc('attach_to_debate', { p_kind: kind, @@ -1352,7 +1434,11 @@ export const useSessionStore = create()( 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 b4f86a9f3..1d3ffbe5c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -253,7 +253,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", @@ -1606,7 +1609,23 @@ "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." }, "libraryPage": { "title": "Source Library", @@ -1707,7 +1726,15 @@ "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": "You are offline", + "offlineNotSaved": "You are offline, so this change was not saved.", + "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…" }, "autoSavedSources": { "undo": "Undo auto-saved sources", @@ -2260,7 +2287,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 a0280a9bd..81c88b7a5 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -253,7 +253,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", @@ -1606,7 +1609,23 @@ "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." }, "libraryPage": { "title": "Biblioteca de fuentes", @@ -1707,7 +1726,15 @@ "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": "Estás sin conexión", + "offlineNotSaved": "Estás sin conexión, así que este cambio no se guardó.", + "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…" }, "autoSavedSources": { "undo": "Deshacer fuentes autoguardadas", @@ -2260,7 +2287,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 69405e4dd..78cd013ed 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -253,7 +253,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", @@ -1606,7 +1609,23 @@ "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." }, "libraryPage": { "title": "Forráskönyvtár", @@ -1707,7 +1726,15 @@ "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": "Nincs internetkapcsolat", + "offlineNotSaved": "Nincs internetkapcsolat, ezért ez a változás nem mentődött.", + "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…" }, "autoSavedSources": { "undo": "Automatikus források visszavonása", @@ -2260,7 +2287,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={<> + + + + ); + })} + + )} + + + )} + !open && setOpenCrossfireId(null)}> + + {openCrossfire && ( + <> + + {openCrossfire.title} + {t('historyPage.crossfireRound', 'Crossfire round')} • {formatEuropeanDateTime(openCrossfire.createdAt)} + +
+ +
+ + )} +
+
+ !open && setCrossfireToDelete(null)}> + + + {t('historyPage.deleteCrossfireTitle', 'Delete Crossfire round?')} + + {t('historyPage.deleteCrossfireDesc', 'This removes the transcript and debrief from your history.')} + + + + {t('common.cancel', 'Cancel')} + {t('common.delete', 'Delete')} + + + !open && setCaseToDelete(null)}> 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 }) {

{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' })}

{oneOff &&

{t('debate.oneOffActive', 'One-off mode is on')}

} diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index b586b7c0a..677d430f2 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 (
{/* The row is clickable for pointers; the title button is the keyboard and screen-reader target. */}
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'}`} > -
+
- {source.type === 'url' ? : } + {pending + ?
-
- {/* On a phone the title gets two lines of its own; one truncated line left a few letters. */} -
+
+ {/* On a phone the controls sit on their own line, so the title has the row's width and two lines. */} +
- {source.origin?.startsWith('http') && ( - 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' })} - > - - )} {/* The row icon already shows the type; on a phone the title needs the room. */} {t(`libraryPage.${source.type}Source`, source.type.toUpperCase())}
-

{formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel}

+

+ {pending ? t('libraryPage.savingSource', 'Saving…') : formatEuropeanDate(source.createdAt)} • {source.origin || manualEntryLabel} +

{/* Badges wrap on narrow rows; the text preview only fits beside them on wider screens. */}
@@ -133,11 +137,24 @@ function SourceRow({
-
+
+ {source.origin?.startsWith('http') && ( + 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' })} + > + + )}
-
@@ -777,7 +860,10 @@ export default function LibraryPage() { /> {addErrors.content && }
- + )} @@ -790,10 +876,12 @@ export default function LibraryPage() { {t('libraryPage.yourKBTitle')}
- - - {visibleSources.length} {t('libraryPage.sourcesCount')} - + {!firstLoadPending && ( + + + {visibleSources.length} {t('libraryPage.sourcesCount')} + + )}
@@ -803,7 +891,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 +907,7 @@ export default function LibraryPage() { )}
-
+
{['all', ...folders].map((folder) => (
)} - - {sourceLibrary.length === 0 ? ( + + {firstLoadPending ? ( +
+
+ ) : sourceLibrary.length === 0 ? (

{t('libraryPage.emptyTitle')}

@@ -930,7 +1023,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')} diff --git a/src/test/stores/library-sources.test.ts b/src/test/stores/library-sources.test.ts new file mode 100644 index 000000000..ac2bc06b0 --- /dev/null +++ b/src/test/stores/library-sources.test.ts @@ -0,0 +1,145 @@ +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'; + +const SAVED_ID = '33333333-3333-4333-8333-333333333333'; +const PAGE = 'https://www.motherly.com/homework-should-be-banned/'; + +function source(patch: Partial = {}): 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((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; 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 without calling it a duplicate', async () => { + const insert = vi.fn(() => ({ select: () => ({ single: async (): Promise<{ data: ReturnType; error: null }> => ({ 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; + }); + + await useSessionStore.getState().addSource({ id: SAVED_ID, createdAt: 1, title: 'Homework', content: 'Text', type: 'url', origin: PAGE }); + expect(insert).toHaveBeenCalledOnce(); + expect(useSessionStore.getState().sourceLibrary.map((item) => item.id)).toEqual([SAVED_ID]); + }); +}); From 1f7500935e87ca658bd81e86052d5935afd49f62 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 21:02:45 +0000 Subject: [PATCH 02/11] Cover the Crossfire History tab and Library offline, dedupe and phone layout in verify recipes Co-Authored-By: Claude Opus 5.5 --- .../features/library-sources.md | 8 +++- .../verify-debatelab/features/practice.md | 1 + .../verify-debatelab/recipes/library.spec.ts | 45 +++++++++++++++++++ .../verify-debatelab/recipes/practice.spec.ts | 18 ++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index d5c2a04cb..2e66f13a7 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -4,8 +4,11 @@ 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, so this change was not saved.` A write that fails online puts the switch back with a toast. +- `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-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. @@ -24,6 +27,9 @@ 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. Set offline back to false. +- **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. - **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. diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 484933dd3..59994b283 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` 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..2d478441f 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -83,3 +83,48 @@ 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, so this change was not saved.")).toBeVisible(); + await expect(page.getByRole("switch", { name: "Toggle Offline study" })).toBeChecked(); + await evidence.shot("01-offline-toggle"); + await page.context().setOffline(false); + const { data } = await admin.from("sources").select("is_active").eq("user_id", await user.userId()).eq("title", "Offline study"); + expect(data).toEqual([{ is_active: true }]); + await evidence.json("02-row-unchanged", data); +}); + +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); +}); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index c24431ef6..23ebced42 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" }).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 }) => { From f2a1ad66e4af43b2ab324cb9f9b85706527901b6 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 21:16:42 +0000 Subject: [PATCH 03/11] Fix Codex r1: source races, Undo rows, and the Crossfire delete dialog - A row still saving is found again by its id, so a folder switch that copies it no longer leaves a second copy behind. - Switch writes for one source run in click order; only the newest decides what shows, and a failure falls back to the last value the server took. - A server reload that read before a switch write keeps the new value. - An Undo row is pending until its insert lands, so it cannot be deleted or changed mid-restore. - The Crossfire delete dialog stays open while the delete runs and shows a failure in place. - Offline toast wording no longer repeats itself. - Vite ignores .verify/ and .ci-failures/, where Playwright's trace pages were reloading the app in the middle of recipes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/library-sources.md | 2 +- .../verify-debatelab/recipes/library.spec.ts | 2 +- src/lib/stores/session-store.ts | 84 ++++++++++++---- src/locales/en.json | 4 +- src/locales/es.json | 4 +- src/locales/hu.json | 4 +- src/pages/HistoryPage.tsx | 46 ++++++--- src/pages/LibraryPage.tsx | 8 +- src/test/stores/library-sources.test.ts | 97 ++++++++++++++++++- vite.config.ts | 5 + 10 files changed, 210 insertions(+), 46 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 2e66f13a7..15e1735cd 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -6,7 +6,7 @@ Users save reference texts to their Library. The Library belongs to the Current - `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved <title> 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, so this change was not saved.` A write that fails online puts the switch back with a toast. +- `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-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). diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index 2d478441f..e66f7a776 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -91,7 +91,7 @@ test("lib-offline: offline, the switch does not move and says the change was not 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, so this change was not saved.")).toBeVisible(); + 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"); await page.context().setOffline(false); diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 930baf67f..350279263 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -468,6 +468,13 @@ export class DuplicateSourceError extends Error { const PENDING_SOURCE_PREFIX = 'pending-'; +/** Counts source switch writes, so a server reload can tell which ones it predates. */ +let sourceWriteSeq = 0; +/** Switch writes still running for a source, 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 }>(); + /** 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); @@ -602,17 +609,16 @@ export const useSessionStore = create<SessionState>()( const known = findSavedPage(get().sourceLibrary, debateId, url); if (known) throw new DuplicateSourceError(known.id); } - const localItem: Source = { - ...source, folder, debateId, isActive, - id: restoredId ?? `${PENDING_SOURCE_PREFIX}${generateId()}`, - createdAt: restoredAt ?? Date.now(), - }; + // 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]; // 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 !== localItem) })); + const withoutLocal = () => set((state) => ({ sourceLibrary: state.sourceLibrary.filter((item) => item.id !== localId) })); let savedSource: Source; try { @@ -674,15 +680,21 @@ export const useSessionStore = create<SessionState>()( withoutLocal(); throw error; } - set((state) => ({ - sourceFolders: mergeSourceFolders(state.sourceFolders, [savedSource]), + set((state) => { + const shown = state.sourceLibrary.some((item) => item.id === localId); // A reload from the server while this saved may already list the row. - sourceLibrary: state.sourceLibrary.includes(localItem) - ? state.sourceLibrary.map((item) => item === localItem ? savedSource : item) - : state.sourceLibrary.some((item) => item.id === savedSource.id) + 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), - })); + : place(state.sourceLibrary, savedSource); + return { sourceFolders: mergeSourceFolders(state.sourceFolders, [savedSource]), sourceLibrary }; + }); markDebateActivity(debateId); }, createSourceFolder: async (folder) => { @@ -701,7 +713,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) { @@ -900,6 +913,7 @@ export const useSessionStore = create<SessionState>()( })); }, 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); @@ -909,23 +923,45 @@ 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 setActive = (isActive: boolean) => set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive } : source), })); - // The switch moves at once; a failed write puts it back and the caller says why. + // Writes for one source run in click order, and only the newest decides + // what the switch shows once they settle. A failed write falls back to + // the last value the server accepted, not to what was on screen. + const seq = ++sourceWriteSeq; + const toggles = sourceToggles.get(id) ?? { latest: 0, confirmed: current.isActive, chain: Promise.resolve() }; + toggles.latest = seq; + sourceToggles.set(id, toggles); + lastSourceToggle.set(id, { value: nextValue, seq }); setActive(nextValue); - try { + const write = toggles.chain.then(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', ); + }); + toggles.chain = write.catch((): void => undefined); + try { + await write; + toggles.confirmed = nextValue; } catch (error) { - setActive(current.isActive); + if (toggles.latest === seq) { + sourceToggles.delete(id); + lastSourceToggle.set(id, { value: toggles.confirmed, seq }); + setActive(toggles.confirmed); + } throw error; } + // A server reload while this ran may have put back the old value. + if (toggles.latest === seq) { + sourceToggles.delete(id); + setActive(nextValue); + } }, clearLibrary: async () => { // The Library belongs to the Current Debate; other debates keep their sources. @@ -1243,6 +1279,7 @@ export const useSessionStore = create<SessionState>()( hydrateFromServer: async () => { if (restoredSessionsBlocked()) return; + const hydrateSeq = sourceWriteSeq; const results = await Promise.allSettled([ supabase.from('cases').select('*').order('created_at', { ascending: false }), supabase.from('attacks').select('*').order('created_at', { ascending: false }), @@ -1320,7 +1357,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, @@ -1346,7 +1383,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 ? { ...source, isActive: toggle.value } : source; + }), + ] + : state.sourceLibrary, serverHydration: casesData && attacksData && brainstormsData && factoidsData && chatsData && crossfiresData ? 'complete' : 'partial', })); }, diff --git a/src/locales/en.json b/src/locales/en.json index 1d3ffbe5c..fbe00f1fe 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1728,8 +1728,8 @@ "mergeFailed": "Could not merge the folders. Try again.", "openOrigin": "Open {{title}} in a new tab", "savingSource": "Saving…", - "offlineTitle": "You are offline", - "offlineNotSaved": "You are offline, so this change was not saved.", + "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", diff --git a/src/locales/es.json b/src/locales/es.json index 81c88b7a5..69007b837 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1728,8 +1728,8 @@ "mergeFailed": "No se pudieron fusionar las carpetas. Inténtalo de nuevo.", "openOrigin": "Abrir {{title}} en una pestaña nueva", "savingSource": "Guardando…", - "offlineTitle": "Estás sin conexión", - "offlineNotSaved": "Estás sin conexión, así que este cambio no se guardó.", + "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", diff --git a/src/locales/hu.json b/src/locales/hu.json index 78cd013ed..67eeda378 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1728,8 +1728,8 @@ "mergeFailed": "Nem sikerült összevonni a mappákat. Próbáld újra.", "openOrigin": "{{title}} megnyitása új lapon", "savingSource": "Mentés…", - "offlineTitle": "Nincs internetkapcsolat", - "offlineNotSaved": "Nincs internetkapcsolat, ezért ez a változás nem mentődött.", + "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", diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index bfce68205..c7369cb5f 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, Flame } 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'; @@ -78,6 +78,8 @@ export default function HistoryPage() { 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(() => { @@ -138,18 +140,20 @@ export default function HistoryPage() { setSparringSessionToDelete(null); }; + // The dialog stays open while the delete runs and closes only once it lands. const confirmDeleteCrossfire = () => { - if (!crossfireToDelete) return; + if (!crossfireToDelete || deletingCrossfire) return; const { id } = crossfireToDelete; - void store.deleteRecordedResult('crossfire', id) + setDeletingCrossfire(true); + setCrossfireDeleteError(false); + store.deleteRecordedResult('crossfire', id) .then(() => { - if (openCrossfireId === id) setOpenCrossfireId(null); + setCrossfireToDelete(null); + setOpenCrossfireId((open) => open === id ? null : open); toast({ title: t('common.deleted', 'Deleted'), description: t('historyPage.crossfireDeleted', 'Crossfire round deleted.') }); }) - .catch(() => { - toast({ title: t('common.error', 'Error'), description: t('historyPage.crossfireDeleteFailed', 'Could not delete the Crossfire round.'), variant: 'destructive' }); - }); - setCrossfireToDelete(null); + .catch(() => setCrossfireDeleteError(true)) + .finally(() => setDeletingCrossfire(false)); }; return ( @@ -454,7 +458,14 @@ export default function HistoryPage() { )} </SheetContent> </Sheet> - <AlertDialog open={Boolean(crossfireToDelete)} onOpenChange={(open) => !open && setCrossfireToDelete(null)}> + <AlertDialog + open={Boolean(crossfireToDelete)} + onOpenChange={(open) => { + if (open || deletingCrossfire) return; + setCrossfireToDelete(null); + setCrossfireDeleteError(false); + }} + > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{t('historyPage.deleteCrossfireTitle', 'Delete Crossfire round?')}</AlertDialogTitle> @@ -462,9 +473,22 @@ export default function HistoryPage() { {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>{t('common.cancel', 'Cancel')}</AlertDialogCancel> - <AlertDialogAction className={buttonVariants({ variant: 'destructive' })} onClick={confirmDeleteCrossfire}>{t('common.delete', 'Delete')}</AlertDialogAction> + <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> diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 677d430f2..0dc9af197 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -275,7 +275,7 @@ export default function LibraryPage() { description: error instanceof DebateRequiredError ? t('debate.sourceNeedsDebate', 'Start or select a debate before saving sources.') : browserIsOffline() || isBrowserNetworkFailure(error) - ? t('libraryPage.offlineNotSaved', 'You are offline, so this change was not saved.') + ? t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.') : undefined, variant: 'destructive', }); @@ -411,8 +411,8 @@ export default function LibraryPage() { const handleToggleSource = (id: string) => { if (browserIsOffline()) { toast({ - title: t('libraryPage.offlineTitle', 'You are offline'), - description: t('libraryPage.offlineNotSaved', 'You are offline, so this change was not saved.'), + title: t('libraryPage.offlineTitle', 'Change not saved'), + description: t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.'), variant: 'destructive', }); return; @@ -421,7 +421,7 @@ export default function LibraryPage() { toast({ title: t('libraryPage.toggleFailed', 'Could not change this source'), description: isBrowserNetworkFailure(error) - ? t('libraryPage.offlineNotSaved', 'You are offline, so this change was not saved.') + ? t('libraryPage.offlineNotSaved', 'You are offline. Reconnect and try again.') : t('libraryPage.toggleFailedDesc', 'It is back to how it was. Try again.'), variant: 'destructive', }); diff --git a/src/test/stores/library-sources.test.ts b/src/test/stores/library-sources.test.ts index ac2bc06b0..d0bfcdd34 100644 --- a/src/test/stores/library-sources.test.ts +++ b/src/test/stores/library-sources.test.ts @@ -3,6 +3,12 @@ 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 }; +} + const SAVED_ID = '33333333-3333-4333-8333-333333333333'; const PAGE = 'https://www.motherly.com/homework-should-be-banned/'; @@ -131,15 +137,98 @@ describe('addSource keeps one row per web page in a debate', () => { expect(useSessionStore.getState().sourceLibrary.map((item) => item.id)).toEqual([SAVED_ID]); }); - it('puts back a deleted page with Undo without calling it a duplicate', async () => { - const insert = vi.fn(() => ({ select: () => ({ single: async (): Promise<{ data: ReturnType<typeof insertedRow>; error: null }> => ({ data: insertedRow(PAGE), error: null }) }) })); + 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; }); - await useSessionStore.getState().addSource({ id: SAVED_ID, createdAt: 1, title: 'Homework', content: 'Text', type: 'url', origin: PAGE }); - expect(insert).toHaveBeenCalledOnce(); + 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(); + 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 landed.promise; return { data: insertedRow(PAGE), error: null }; } }) }), + // Switching the folder off rewrites every row in it, the saving one included. + update: () => ({ eq: () => ({ eq: () => ({ eq: async () => ({ error: null as null }) }) }) }), + } as never; + }); + + const add = useSessionStore.getState().addSource({ title: 'Homework', content: 'Text', type: 'url', origin: PAGE, folder: 'General' }); + await vi.waitFor(() => expect(useSessionStore.getState().sourceLibrary).toHaveLength(1)); + await useSessionStore.getState().setFolderActive('General', false); + 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); + await expect(off).rejects.toThrow(); + 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]); + + }); +}); 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, }, From f287d80f795b3de2206e0707e73c7db48c4da6d5 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 21:27:40 +0000 Subject: [PATCH 04/11] Test the research toast count and that /app and Ask agree on source counts (MD-05) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/library-sources.md | 2 ++ .../verify-debatelab/recipes/library.spec.ts | 17 +++++++++++++++++ src/lib/ai/sources.test.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 15e1735cd..68e6cf83e 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -11,6 +11,7 @@ Users save reference texts to their Library. The Library belongs to the Current - `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. @@ -32,6 +33,7 @@ Preconditions: - **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/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index e66f7a776..0fec7efec 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -128,3 +128,20 @@ test("lib-dedupe and lib-layout: a page saved once per debate, and long titles k 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/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' }); + }); }); From f57bc805b833457219bbfc04396d63280f87f080 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 21:32:00 +0000 Subject: [PATCH 05/11] Fix Codex r2: one queue for every switch write, and named Open buttons - The switch, a folder's Activate/Deactivate and the editor's Save all join each source's write queue, and a server reload keeps the newest of them, so a folder activation is no longer overwritten by an older toggle. - updateSource writes and undoes only the fields an edit changes, so a research metadata refresh cannot save a switch value that is still being written or about to fail. - Each Crossfire Open button names its round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../verify-debatelab/features/practice.md | 2 +- .../verify-debatelab/recipes/practice.spec.ts | 2 +- src/lib/stores/session-store.ts | 203 +++++++++++------- src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/hu.json | 3 +- src/pages/HistoryPage.tsx | 8 +- src/test/stores/library-sources.test.ts | 54 +++++ 8 files changed, 197 insertions(+), 81 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 59994b283..05395aca5 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -9,7 +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` 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-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/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index 23ebced42..2fc4129bf 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -90,7 +90,7 @@ test("cross-turn and cross-debrief: questioning the AI gets an answer, a Jev sig const row = page.getByTestId("history-crossfire-row"); await expect(row).toHaveCount(1); await expect(row).toContainText(resolution); - await row.getByRole("button", { name: "Open" }).click(); + 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)); diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index 350279263..b84c6c95c 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -470,11 +470,75 @@ const PENDING_SOURCE_PREFIX = 'pending-'; /** Counts source switch writes, so a server reload can tell which ones it predates. */ let sourceWriteSeq = 0; -/** Switch writes still running for a source, and the last value the server accepted. */ +/** 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. */ +async function finishSourceActiveWrite({ seq, value, entries, done }: SourceActiveWrite): 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); + 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); @@ -723,46 +787,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; } @@ -899,18 +972,20 @@ 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); + 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).eq('folder', normalized), + 'update source folder activity', + ); + }); + await finishSourceActiveWrite(activeWrite); }, removeSource: async (id) => { if (isPendingSource({ id })) throw new Error('[SessionStore] This source is still saving'); @@ -925,19 +1000,10 @@ export const useSessionStore = create<SessionState>()( if (!current) return; if (isPendingSource(current)) throw new Error('[SessionStore] This source is still saving'); const nextValue = !current.isActive; - const setActive = (isActive: boolean) => set((state) => ({ - sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive } : source), - })); - // Writes for one source run in click order, and only the newest decides - // what the switch shows once they settle. A failed write falls back to - // the last value the server accepted, not to what was on screen. - const seq = ++sourceWriteSeq; - const toggles = sourceToggles.get(id) ?? { latest: 0, confirmed: current.isActive, chain: Promise.resolve() }; - toggles.latest = seq; - sourceToggles.set(id, toggles); - lastSourceToggle.set(id, { value: nextValue, seq }); - setActive(nextValue); - const write = toggles.chain.then(async () => { + // 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( @@ -945,23 +1011,10 @@ export const useSessionStore = create<SessionState>()( 'toggle source activity', ); }); - toggles.chain = write.catch((): void => undefined); - try { - await write; - toggles.confirmed = nextValue; - } catch (error) { - if (toggles.latest === seq) { - sourceToggles.delete(id); - lastSourceToggle.set(id, { value: toggles.confirmed, seq }); - setActive(toggles.confirmed); - } - throw error; - } - // A server reload while this ran may have put back the old value. - if (toggles.latest === seq) { - sourceToggles.delete(id); - setActive(nextValue); - } + set((state) => ({ + sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive: nextValue } : source), + })); + await finishSourceActiveWrite(activeWrite); }, clearLibrary: async () => { // The Library belongs to the Current Debate; other debates keep their sources. diff --git a/src/locales/en.json b/src/locales/en.json index f77979271..8babbdadb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1628,7 +1628,8 @@ "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." + "crossfireNoDebrief": "This round has no debrief.", + "openCrossfireAria": "Open Crossfire round: {{title}}" }, "libraryPage": { "title": "Source Library", diff --git a/src/locales/es.json b/src/locales/es.json index 821f9d110..5e42f7a1e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1628,7 +1628,8 @@ "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." + "crossfireNoDebrief": "Esta ronda no tiene análisis.", + "openCrossfireAria": "Abrir ronda de interrogatorio: {{title}}" }, "libraryPage": { "title": "Biblioteca de fuentes", diff --git a/src/locales/hu.json b/src/locales/hu.json index e59e001c6..7848b4aec 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1628,7 +1628,8 @@ "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." + "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", diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index c7369cb5f..c99e03808 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -420,7 +420,13 @@ export default function HistoryPage() { </p> </div> <div className="flex shrink-0 gap-2"> - <Button variant="outline" size="sm" className="pointer-coarse:h-11" onClick={() => setOpenCrossfireId(item.id)}> + <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 diff --git a/src/test/stores/library-sources.test.ts b/src/test/stores/library-sources.test.ts index d0bfcdd34..705b3ffb2 100644 --- a/src/test/stores/library-sources.test.ts +++ b/src/test/stores/library-sources.test.ts @@ -232,3 +232,57 @@ describe('toggleSource with writes in flight', () => { }); }); + +describe('other writes and the switch', () => { + /** 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, then: (done: (value: unknown) => unknown, fail: (reason: unknown) => unknown) => result().then(done, fail) }; + return query; + } + + 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]); + }); +}); From c62d1f356606092775fe6dc4b6b12649448cc5bd Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 22:26:07 +0000 Subject: [PATCH 06/11] Fix review r3: reloads mid-write, folder writes, replaced failures - A reload keeps the value of any switch write already running when it started, not only writes that began after it. - A folder's Activate/Deactivate updates only the rows the Library shows, so a row still saving cannot take the folder's value on the server alone. - A failed switch write that a newer click already replaced is not reported; the newer write's outcome is what shows. - Debate counts leave out rows still saving, matching what tools send. - The identity test now copies a saving row through a folder rename. - docs/architecture.md describes the shared dedupe and the write queue. - lib-add waits for the pause to reach the server before leaving the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../verify-debatelab/recipes/library.spec.ts | 3 + docs/architecture.md | 4 +- .../features/debate/CurrentDebateStrip.tsx | 7 +- src/lib/stores/session-store.ts | 21 ++++-- src/test/stores/library-sources.test.ts | 75 ++++++++++++++++--- 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index 0fec7efec..e3bf65f38 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"); 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/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx index 51672753c..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 @@ -19,8 +19,9 @@ export function sideLabelKey(side: DebateSide): string { */ // 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); - const activeSources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId && source.isActive).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, diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index b84c6c95c..32ba3dc78 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -507,8 +507,12 @@ function beginSourceActiveWrite(ids: string[], value: boolean, write: () => Prom return { seq, value, entries, done }; } -/** Shows the newest write's value once it lands, or the last value the server took if it fails. */ -async function finishSourceActiveWrite({ seq, value, entries, done }: SourceActiveWrite): Promise<void> { +/** + * 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) => ({ @@ -526,6 +530,7 @@ async function finishSourceActiveWrite({ seq, value, entries, done }: SourceActi fallback.set(id, entry.confirmed); } show(fallback); + if (quietWhenReplaced && fallback.size === 0) return; throw error; } const settled = new Map<string, boolean>(); @@ -976,12 +981,15 @@ export const useSessionStore = create<SessionState>()( 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).eq('folder', normalized), + .eq('user_id', userId).eq('debate_id', debateId).in('id', ids), 'update source folder activity', ); }); @@ -1014,7 +1022,8 @@ export const useSessionStore = create<SessionState>()( set((state) => ({ sourceLibrary: state.sourceLibrary.map((source) => source.id === id ? { ...source, isActive: nextValue } : source), })); - await finishSourceActiveWrite(activeWrite); + // 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. @@ -1333,6 +1342,8 @@ 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 }), @@ -1442,7 +1453,7 @@ export const useSessionStore = create<SessionState>()( ...state.sourceLibrary.filter(isPendingSource), ...serverSources.map((source) => { const toggle = lastSourceToggle.get(source.id); - return toggle && toggle.seq > hydrateSeq ? { ...source, isActive: toggle.value } : source; + return toggle && (toggle.seq > hydrateSeq || writesInFlight.has(source.id)) ? { ...source, isActive: toggle.value } : source; }), ] : state.sourceLibrary, diff --git a/src/test/stores/library-sources.test.ts b/src/test/stores/library-sources.test.ts index 705b3ffb2..6c652f8cd 100644 --- a/src/test/stores/library-sources.test.ts +++ b/src/test/stores/library-sources.test.ts @@ -9,6 +9,12 @@ function deferred() { 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/'; @@ -159,19 +165,22 @@ describe('addSource keeps one row per web page in a debate', () => { 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: async (): Promise<{ error: null }> => ({ error: null }) } as never; + 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), error: null }; } }) }), - // Switching the folder off rewrites every row in it, the saving one included. - update: () => ({ eq: () => ({ eq: () => ({ eq: async () => ({ 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: 'General' }); + const add = useSessionStore.getState().addSource({ title: 'Homework', content: 'Text', type: 'url', origin: PAGE, folder: 'Research' }); await vi.waitFor(() => expect(useSessionStore.getState().sourceLibrary).toHaveLength(1)); - await useSessionStore.getState().setFolderActive('General', false); + 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; @@ -195,7 +204,8 @@ describe('toggleSource with writes in flight', () => { const off = useSessionStore.getState().toggleSource(SAVED_ID); const on = useSessionStore.getState().toggleSource(SAVED_ID); - await expect(off).rejects.toThrow(); + // 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); @@ -234,11 +244,6 @@ describe('toggleSource with writes in flight', () => { }); describe('other writes and the switch', () => { - /** 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, then: (done: (value: unknown) => unknown, fail: (reason: unknown) => unknown) => result().then(done, fail) }; - return query; - } it('does not save a switch value with a metadata refresh while the switch write fails', async () => { useSessionStore.setState({ sourceLibrary: [source({ isActive: true })] }); @@ -285,4 +290,50 @@ describe('other writes and the switch', () => { 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'); + }); }); + From 49ad557fe5965ad483a6e2becff04e5a388cd205 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 22:31:46 +0000 Subject: [PATCH 07/11] Fix review r4: say when a folder switch or source edit fails Folder Activate/Deactivate and the source editor's Save did not catch a failed write, so a refused change rolled back with no message. Both now show a toast, offline-aware like the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/locales/en.json | 3 ++- src/locales/es.json | 3 ++- src/locales/hu.json | 3 ++- src/pages/LibraryPage.tsx | 39 ++++++++++++++++++++++++++++++--------- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 8babbdadb..fd7f81cfe 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1738,7 +1738,8 @@ "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…" + "loadingSources": "Loading sources…", + "folderUpdateFailed": "Could not change this folder" }, "autoSavedSources": { "undo": "Undo auto-saved sources", diff --git a/src/locales/es.json b/src/locales/es.json index 5e42f7a1e..9b29478a0 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1738,7 +1738,8 @@ "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…" + "loadingSources": "Cargando fuentes…", + "folderUpdateFailed": "No se pudo cambiar esta carpeta" }, "autoSavedSources": { "undo": "Deshacer fuentes autoguardadas", diff --git a/src/locales/hu.json b/src/locales/hu.json index 7848b4aec..1b46f3c00 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1738,7 +1738,8 @@ "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…" + "loadingSources": "Források betöltése…", + "folderUpdateFailed": "Nem sikerült módosítani ezt a mappát" }, "autoSavedSources": { "undo": "Automatikus források visszavonása", diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 0dc9af197..e59044b94 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -578,7 +578,17 @@ 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 handleSaveSourceDraft = async () => { if (!selectedSource) return; @@ -587,14 +597,25 @@ export default function LibraryPage() { 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, - }); + try { + await updateSource(selectedSource.id, { + title: sourceDraft.title, + content: sourceDraft.content, + type: sourceDraft.type, + origin: sourceDraft.origin, + folder: sourceDraft.folder, + isActive: sourceDraft.isActive, + }); + } 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; + } toast({ title: t('libraryPage.sourceUpdated', 'Source updated'), description: t('libraryPage.sourceUpdatedDesc', 'Your source changes were saved.') }); }; const handleCopySource = async () => { From 74334e7a1e568c625e1faee890b4784c529b24a1 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 22:35:44 +0000 Subject: [PATCH 08/11] Keep the source editor's draft when a save fails The draft reloaded whenever the source object changed, so a rolled-back save or a background refresh replaced what the student had typed. It now loads once per opened source. lib-offline covers a failed offline save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../verify-debatelab/features/library-sources.md | 2 +- .../skills/verify-debatelab/recipes/library.spec.ts | 10 +++++++++- src/pages/LibraryPage.tsx | 10 +++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 68e6cf83e..28c26d948 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -28,7 +28,7 @@ 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. Set offline back to false. +- **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. - **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. diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index e3bf65f38..9c3913328 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -97,10 +97,18 @@ test("lib-offline: offline, the switch does not move and says the change was not 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 { data } = await admin.from("sources").select("is_active").eq("user_id", await user.userId()).eq("title", "Offline study"); expect(data).toEqual([{ is_active: true }]); - await evidence.json("02-row-unchanged", data); + await evidence.json("03-row-unchanged", data); }); 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 }) => { diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index e59044b94..510d4a0eb 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -219,6 +219,7 @@ 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); const [newSource, setNewSource] = useState({ title: '', content: '', @@ -503,7 +504,14 @@ export default function LibraryPage() { const selectedSource = selectedSourceId ? sourceLibrary.find((source) => source.id === selectedSourceId) || null : null; useEffect(() => { - if (!selectedSource) return; + // The draft loads once per opened source. A save that fails, or a change + // made elsewhere while the sheet is open, must not wipe what was typed. + if (!selectedSource) { + draftLoadedFor.current = null; + return; + } + if (draftLoadedFor.current === selectedSource.id) return; + draftLoadedFor.current = selectedSource.id; setDraftErrors({}); setSourceDraft({ title: selectedSource.title, From fc5cd22197699de7471fd28bf488253fd3bdb33e Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 22:45:58 +0000 Subject: [PATCH 09/11] Save only the source fields edited in the sheet The editor sent every field on Save, so values loaded when the sheet opened could overwrite a switch or research change that landed while it was open. It now sends only what differs from what it loaded, and Save is disabled while a save runs. lib-offline retries the kept text online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/library-sources.md | 2 +- .../verify-debatelab/recipes/library.spec.ts | 10 +++-- src/pages/LibraryPage.tsx | 39 ++++++++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 28c26d948..3fc8562c0 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -28,7 +28,7 @@ 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. +- **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. diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index 9c3913328..ca42e5ab3 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -106,9 +106,13 @@ test("lib-offline: offline, the switch does not move and says the change was not 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 { data } = await admin.from("sources").select("is_active").eq("user_id", await user.userId()).eq("title", "Offline study"); - expect(data).toEqual([{ is_active: true }]); - await evidence.json("03-row-unchanged", data); + 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-dedupe and lib-layout: a page saved once per debate, and long titles keep their room on a phone", async ({ signedIn: page, user, evidence }) => { diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 510d4a0eb..b2096a74a 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -220,6 +220,9 @@ export default function LibraryPage() { const draftTitleRef = useRef<HTMLInputElement>(null); const draftContentRef = useRef<HTMLTextAreaElement>(null); const draftLoadedFor = useRef<string | null>(null); + // What the sheet loaded, so Save sends only the fields the student changed. + const draftBase = useRef<typeof sourceDraft | null>(null); + const [isSavingDraft, setIsSavingDraft] = useState(false); const [newSource, setNewSource] = useState({ title: '', content: '', @@ -512,15 +515,17 @@ export default function LibraryPage() { } if (draftLoadedFor.current === selectedSource.id) return; draftLoadedFor.current = selectedSource.id; - setDraftErrors({}); - setSourceDraft({ + const loaded = { title: selectedSource.title, content: selectedSource.content, type: selectedSource.type, origin: selectedSource.origin || '', folder: selectedSource.folder || 'General', isActive: selectedSource.isActive, - }); + }; + draftBase.current = loaded; + setDraftErrors({}); + setSourceDraft(loaded); }, [selectedSource]); useEffect(() => { setFolderRenameValue(selectedFolder === 'all' ? '' : selectedFolder); @@ -599,21 +604,25 @@ export default function LibraryPage() { } }; 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(); + // 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 base = draftBase.current; + const draft = sourceDraft; + const patch = Object.fromEntries( + (Object.keys(draft) as Array<keyof typeof draft>) + .filter((key) => !base || draft[key] !== base[key]) + .map((key) => [key, draft[key]]), + ) as Partial<typeof draft>; + setIsSavingDraft(true); try { - await updateSource(selectedSource.id, { - title: sourceDraft.title, - content: sourceDraft.content, - type: sourceDraft.type, - origin: sourceDraft.origin, - folder: sourceDraft.folder, - isActive: sourceDraft.isActive, - }); + await updateSource(selectedSource.id, patch); + draftBase.current = draft; } catch (error) { toast({ title: t('libraryPage.saveFailed', 'Could not save this source.'), @@ -623,6 +632,8 @@ export default function LibraryPage() { variant: 'destructive', }); return; + } finally { + setIsSavingDraft(false); } toast({ title: t('libraryPage.sourceUpdated', 'Source updated'), description: t('libraryPage.sourceUpdatedDesc', 'Your source changes were saved.') }); }; @@ -1241,8 +1252,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> From dc7c626947374a7d4579f5b728c2327ebe0bf624 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 22:51:11 +0000 Subject: [PATCH 10/11] Track the fields edited in the source sheet, not a snapshot Save sent the fields that differed from what the sheet loaded, which went stale when the stored source rolled back and could be overwritten by a save that finished after the sheet moved to another source. The sheet now records which fields the student edited: those keep what was typed and are all Save sends; the rest follow the stored source while it is open. A finished save clears its marks only if the same source is still open. New recipe lib-edit-while-switching covers a switch that fails while the editor is open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/library-sources.md | 1 + .../verify-debatelab/recipes/library.spec.ts | 30 +++++++++ src/pages/LibraryPage.tsx | 65 ++++++++++++------- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index 3fc8562c0..a71bfed96 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -7,6 +7,7 @@ Users save reference texts to their Library. The Library belongs to the Current - `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved <title> 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. - `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). diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index ca42e5ab3..ae9d0d32b 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -115,6 +115,36 @@ test("lib-offline: offline, the switch does not move and says the change was not 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"); + 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"); + 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(); diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index b2096a74a..75aa252d3 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -220,8 +220,9 @@ export default function LibraryPage() { const draftTitleRef = useRef<HTMLInputElement>(null); const draftContentRef = useRef<HTMLTextAreaElement>(null); const draftLoadedFor = useRef<string | null>(null); - // What the sheet loaded, so Save sends only the fields the student changed. - const draftBase = useRef<typeof sourceDraft | null>(null); + // Fields the student edited in the sheet. Save sends only these; the others + // follow the stored source while the sheet is open. + const touchedDraftFields = useRef(new Set<keyof typeof sourceDraft>()); const [isSavingDraft, setIsSavingDraft] = useState(false); const [newSource, setNewSource] = useState({ title: '', @@ -507,15 +508,16 @@ export default function LibraryPage() { const selectedSource = selectedSourceId ? sourceLibrary.find((source) => source.id === selectedSourceId) || null : null; useEffect(() => { - // The draft loads once per opened source. A save that fails, or a change - // made elsewhere while the sheet is open, must not wipe what was typed. + // 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; } - if (draftLoadedFor.current === selectedSource.id) return; - draftLoadedFor.current = selectedSource.id; - const loaded = { + const stored = { title: selectedSource.title, content: selectedSource.content, type: selectedSource.type, @@ -523,9 +525,21 @@ export default function LibraryPage() { folder: selectedSource.folder || 'General', isActive: selectedSource.isActive, }; - draftBase.current = loaded; - setDraftErrors({}); - setSourceDraft(loaded); + if (draftLoadedFor.current !== selectedSource.id) { + draftLoadedFor.current = selectedSource.id; + 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(() => { setFolderRenameValue(selectedFolder === 'all' ? '' : selectedFolder); @@ -603,6 +617,10 @@ export default function LibraryPage() { }); } }; + const editDraft = (patch: Partial<typeof sourceDraft>) => { + for (const key of Object.keys(patch) as Array<keyof typeof sourceDraft>) touchedDraftFields.current.add(key); + setSourceDraft((draft) => ({ ...draft, ...patch })); + }; const handleSaveSourceDraft = async () => { if (!selectedSource || isSavingDraft) return; const errors = requiredErrors(sourceDraft.title, sourceDraft.content); @@ -612,17 +630,14 @@ export default function LibraryPage() { // 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 base = draftBase.current; - const draft = sourceDraft; - const patch = Object.fromEntries( - (Object.keys(draft) as Array<keyof typeof draft>) - .filter((key) => !base || draft[key] !== base[key]) - .map((key) => [key, draft[key]]), - ) as Partial<typeof draft>; + const sourceId = selectedSource.id; + const sent = Array.from(touchedDraftFields.current); + const patch = Object.fromEntries(sent.map((key) => [key, sourceDraft[key]])) as Partial<typeof sourceDraft>; setIsSavingDraft(true); try { - await updateSource(selectedSource.id, patch); - draftBase.current = draft; + await updateSource(sourceId, patch); + // The sheet may show another source by now; its edits are its own. + if (draftLoadedFor.current === sourceId) for (const key of sent) touchedDraftFields.current.delete(key); } catch (error) { toast({ title: t('libraryPage.saveFailed', 'Could not save this source.'), @@ -1169,7 +1184,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 }); }} /> @@ -1178,7 +1193,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> @@ -1191,7 +1206,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> @@ -1209,7 +1224,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"> @@ -1219,7 +1234,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> @@ -1233,7 +1248,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 }); }} /> From 70e5dd74ceed52294f1fefc5eb6139211f50c832 Mon Sep 17 00:00:00 2001 From: Adam <adam.gaborjani.szabo@gmail.com> Date: Thu, 22 Oct 2026 22:56:19 +0000 Subject: [PATCH 11/11] Keep text typed during a save marked as an edit A finished save cleared the edit mark of every field it sent, even one the student changed again while it ran, so that text was never sent and could be replaced by the stored copy. Each field now carries an edit count and each sheet opening a number; a save clears a mark only when neither changed. lib-edit-while-switching types on during a slow save and checks the next Save sends it, and first checks the editor opened mid-write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../features/library-sources.md | 2 +- .../verify-debatelab/recipes/library.spec.ts | 18 ++++++++++++ src/pages/LibraryPage.tsx | 28 ++++++++++++------- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index a71bfed96..8595436c9 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -7,7 +7,7 @@ Users save reference texts to their Library. The Library belongs to the Current - `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved <title> 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. +- `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). diff --git a/.claude/skills/verify-debatelab/recipes/library.spec.ts b/.claude/skills/verify-debatelab/recipes/library.spec.ts index ae9d0d32b..35b0cb361 100644 --- a/.claude/skills/verify-debatelab/recipes/library.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/library.spec.ts @@ -132,6 +132,8 @@ test("lib-edit-while-switching: the editor follows a switch that rolls back, and 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(); @@ -142,6 +144,22 @@ test("lib-edit-while-switching: the editor follows a switch that rolls back, and 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()); }); diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 75aa252d3..aa6add7e0 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -220,9 +220,11 @@ export default function LibraryPage() { const draftTitleRef = useRef<HTMLInputElement>(null); const draftContentRef = useRef<HTMLTextAreaElement>(null); const draftLoadedFor = useRef<string | null>(null); - // Fields the student edited in the sheet. Save sends only these; the others - // follow the stored source while the sheet is open. - const touchedDraftFields = useRef(new Set<keyof typeof sourceDraft>()); + // 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: '', @@ -527,6 +529,7 @@ export default function LibraryPage() { }; if (draftLoadedFor.current !== selectedSource.id) { draftLoadedFor.current = selectedSource.id; + draftOpening.current += 1; touchedDraftFields.current.clear(); setDraftErrors({}); setSourceDraft(stored); @@ -618,7 +621,8 @@ export default function LibraryPage() { } }; const editDraft = (patch: Partial<typeof sourceDraft>) => { - for (const key of Object.keys(patch) as Array<keyof typeof sourceDraft>) touchedDraftFields.current.add(key); + 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 () => { @@ -630,14 +634,18 @@ export default function LibraryPage() { // 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 sourceId = selectedSource.id; - const sent = Array.from(touchedDraftFields.current); - const patch = Object.fromEntries(sent.map((key) => [key, sourceDraft[key]])) as Partial<typeof sourceDraft>; + 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(sourceId, patch); - // The sheet may show another source by now; its edits are its own. - if (draftLoadedFor.current === sourceId) for (const key of sent) touchedDraftFields.current.delete(key); + 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.'),