{turn.text}
+ {turn.kind === 'answer' && turn.signal ? ( +diff --git a/.claude/skills/verify-debatelab/features/library-sources.md b/.claude/skills/verify-debatelab/features/library-sources.md index d5c2a04cb..8595436c9 100644 --- a/.claude/skills/verify-debatelab/features/library-sources.md +++ b/.claude/skills/verify-debatelab/features/library-sources.md @@ -4,10 +4,15 @@ Users save reference texts to their Library. The Library belongs to the Current ## Sub-features -- `lib-add` adds a source with a title and content. It appears with its toggle on, and the banner `Grounding Active` shows. +- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved
{emptyLabel}
+ ) : ( +{item.claim}
+{empty}
+ ) : ( +{item.question}
+{item.reason}
+{debrief.strongestExchange.claim}
+{empty}
+ )} +{turn.text}
+ {turn.kind === 'answer' && turn.signal ? ( ++ + {t('historyPage.crossfireUnreadable', 'This round was saved in an older format and cannot be shown.')} +
+ ); + } + return ( +{t('historyPage.crossfireNoDebrief', 'This round has no debrief.')}
+ )} +- {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..5d2ab46da 100644 --- a/src/components/features/debate/CurrentDebateStrip.tsx +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { shouldPrefillMotion } from '@/lib/debate/motion'; import { useCurrentDebate, useDebateStore, type DebateSide } from '@/lib/stores/debate-store'; -import { useSessionStore } from '@/lib/stores/session-store'; +import { isPendingSource, useSessionStore } from '@/lib/stores/session-store'; import { useSparringStore } from '@/lib/stores/sparring-store'; // eslint-disable-next-line react-refresh/only-export-components @@ -12,16 +12,22 @@ export function sideLabelKey(side: DebateSide): string { return `debate.side.${side}`; } -/** Sources and saved outputs that belong to one debate. */ +/** + * Sources and saved outputs that belong to one debate. `activeSources` counts + * the sources switched on, the ones a request can use, so every page that + * names a count agrees with what Ask, Generate and the others say they use. + */ // eslint-disable-next-line react-refresh/only-export-components export function useDebateCounts(debateId: string | null | undefined) { - const sources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId).length : 0); + // A row still saving is not counted until it is a source the tools can send. + const sources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId && !isPendingSource(source)).length : 0); + const activeSources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId && source.isActive && !isPendingSource(source)).length : 0); const outputs = useSessionStore((state) => debateId ? [ state.savedCases, state.savedAttacks, state.savedBrainstorms, state.savedFactoids, state.savedChats, state.savedFlowcharts, state.savedCrossfires, ].reduce((count, list) => count + list.filter((item) => item.debateId === debateId).length, 0) : 0); const sparring = useSparringStore((state) => debateId ? state.savedSessions.filter((session) => session.debateId === debateId).length : 0); - return { sources, outputs: outputs + sparring }; + return { sources, activeSources, outputs: outputs + sparring }; } /** diff --git a/src/lib/ai/sources.test.ts b/src/lib/ai/sources.test.ts index 21ee55b6d..3b9671421 100644 --- a/src/lib/ai/sources.test.ts +++ b/src/lib/ai/sources.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from '@/hooks/use-toast'; import { autosaveDiscoveredSources, normalizeSourceUrl } from './sources'; + +vi.mock('@/hooks/use-toast', () => ({ toast: vi.fn() })); import { useSessionStore, type Source } from '@/lib/stores/session-store'; const DEBATE = 'debate-1'; @@ -31,6 +34,7 @@ describe('autosaveDiscoveredSources', () => { const original = useSessionStore.getState(); beforeEach(() => { + vi.mocked(toast).mockReset(); addSource.mockReset(); updateSource.mockReset(); // The stubbed write lands in the library like the real one, a moment later. @@ -89,4 +93,18 @@ describe('autosaveDiscoveredSources', () => { expect(addSource).toHaveBeenCalledTimes(2); }); + + it('says how many sources research added, counting only new rows', async () => { + await autosaveDiscoveredSources([{ title: 'Homework study', url: PAGE }], 'generate', DEBATE); + vi.mocked(toast).mockReset(); + + await autosaveDiscoveredSources([ + { title: 'Homework study', url: `${PAGE}?utm_source=exa` }, + { title: 'A', url: 'https://example.org/a' }, + { title: 'B', url: 'https://example.org/b' }, + ], 'attack', DEBATE); + + expect(toast).toHaveBeenCalledOnce(); + expect(vi.mocked(toast).mock.calls[0][0]).toMatchObject({ title: 'Added 2 sources to your Library' }); + }); }); diff --git a/src/lib/ai/sources.ts b/src/lib/ai/sources.ts index 6a1feac9e..07d4245e2 100644 --- a/src/lib/ai/sources.ts +++ b/src/lib/ai/sources.ts @@ -1,4 +1,5 @@ import { + DuplicateSourceError, useSessionStore, type SourceAssessmentUpdate, type SourceProvenance, @@ -7,6 +8,7 @@ import { import { activeDebateId } from '../stores/debate-store'; import { toast } from '@/hooks/use-toast'; import { validateUrl } from '@/lib/validation/url'; +import { findSavedPage, normalizeSourceUrl } from '@/lib/source-url'; import i18n from '@/i18n'; /** A web source discovered during research, with auditable ranking metadata from the edge function. */ @@ -117,36 +119,7 @@ export function parseSourceAssessments(value: unknown): SourceAssessmentUpdate[] type AutosaveFeature = 'generate' | 'attack' | 'brainstorm' | 'factoids'; -// Query parameters that only say where a click came from. Two links that differ -// only in these open the same page. -const TRACKING_PARAMS = /^(utm_.+|fbclid|gclid|dclid|gbraid|wbraid|msclkid|yclid|mc_cid|mc_eid|igshid|_ga|_gl|ref_src)$/i; - -/** - * One key per web page: the host lower-cased without "www.", no scheme, no - * fragment, no tracking parameters, no trailing slash. Null when it is not a URL. - */ -export function normalizeSourceUrl(url: string): string | null { - let parsed: URL; - try { - parsed = new URL(url.trim()); - } catch { - return null; - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; - const host = parsed.host.toLowerCase().replace(/^www\./, ''); - const params = new URLSearchParams(); - parsed.searchParams.forEach((value, key) => { - if (!TRACKING_PARAMS.test(key)) params.append(key, value); - }); - const query = params.toString(); - const path = parsed.pathname.replace(/\/+$/, ''); - return `${host}${path}${query ? `?${query}` : ''}`; -} - -function savedSourceUrl(source: { origin?: string; metadata?: { url?: string } }): string | null { - const url = source.metadata?.url ?? source.origin; - return url ? normalizeSourceUrl(url) : null; -} +export { normalizeSourceUrl }; // Generate, Attack, Brainstorm and Factoids can finish at the same time. Each // batch waits for the one before it, so it sees the rows that batch saved. @@ -176,6 +149,7 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF }); if (retainedSources.length === 0) return; + let added = 0; for (const source of retainedSources) { const session = useSessionStore.getState(); const metadata = { @@ -187,10 +161,7 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF ...(source.semantic !== undefined ? { semantic: source.semantic } : {}), capturedVia, }; - const key = normalizeSourceUrl(source.url); - const existing = key - ? session.sourceLibrary.find((saved) => saved.debateId === targetDebateId && savedSourceUrl(saved) === key) - : undefined; + const existing = findSavedPage(session.sourceLibrary, targetDebateId, source.url); try { if (existing) { // Already in this debate's Library: refresh what research learned about @@ -211,7 +182,10 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF isActive: source.qualityLabel === 'strong' || source.qualityLabel === 'moderate', metadata, }); - } catch { + added += 1; + } catch (error) { + // Another tab saved this page first; the debate already has it. + if (error instanceof DuplicateSourceError) continue; toast({ title: i18n.t('aiClient.sourceSaveFailed'), description: i18n.t('aiClient.sourceSaveFailedDesc', { title: source.title }), @@ -219,5 +193,11 @@ async function autosaveBatch(sources: DiscoveredSource[], capturedVia: AutosaveF }); } } - console.log(`[AI Client] Saved ${retainedSources.length} sources to library`); + // Research changes what grounds the next request, so say so. + if (added > 0) { + toast({ + title: i18n.t('aiClient.sourcesAutoSaved', { count: added }), + description: i18n.t('aiClient.sourcesAutoSavedDesc'), + }); + } } diff --git a/src/lib/source-url.ts b/src/lib/source-url.ts new file mode 100644 index 000000000..0e81aff32 --- /dev/null +++ b/src/lib/source-url.ts @@ -0,0 +1,42 @@ +// Query parameters that only say where a click came from. Two links that differ +// only in these open the same page. +const TRACKING_PARAMS = /^(utm_.+|fbclid|gclid|dclid|gbraid|wbraid|msclkid|yclid|mc_cid|mc_eid|igshid|_ga|_gl|ref_src)$/i; + +/** + * One key per web page: the host lower-cased without "www.", no scheme, no + * fragment, no tracking parameters, no trailing slash. Null when it is not a URL. + */ +export function normalizeSourceUrl(url: string): string | null { + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + return null; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + const host = parsed.host.toLowerCase().replace(/^www\./, ''); + const params = new URLSearchParams(); + parsed.searchParams.forEach((value, key) => { + if (!TRACKING_PARAMS.test(key)) params.append(key, value); + }); + const query = params.toString(); + const path = parsed.pathname.replace(/\/+$/, ''); + return `${host}${path}${query ? `?${query}` : ''}`; +} + +/** The page a saved source came from: research keeps it in metadata, imports in origin. */ +export function savedSourceUrl(source: { origin?: string; metadata?: { url?: string } }): string | null { + const url = source.metadata?.url ?? source.origin; + return url ? normalizeSourceUrl(url) : null; +} + +/** The source in `debateId` that already holds this page, if any. */ +export function findSavedPage