From 2bd9211abc1ea25a8f1b3271b8a9d179de58e835 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 11 Jul 2026 18:47:27 -0300 Subject: [PATCH] refactor(frontend): split GeneratePodcastDialog and fix token-count races (#1067) Extract ContentSelectionPanel and shared selection helpers into their own modules, deduplicate the sources/notes context-config reduction into selectionsToContextConfigs, delete the obsolete translation cache (plain react-i18next t() now), debounce the token/char counter with a stale-response guard, and close the dialog when the episode refetch resolves instead of after a fixed 500ms timer. --- CHANGELOG.md | 3 + .../podcasts/ContentSelectionPanel.tsx | 313 +++++++++++ .../podcasts/GeneratePodcastDialog.tsx | 499 ++---------------- .../podcasts/generate-podcast-selection.ts | 89 ++++ 4 files changed, 455 insertions(+), 449 deletions(-) create mode 100644 frontend/src/components/podcasts/ContentSelectionPanel.tsx create mode 100644 frontend/src/components/podcasts/generate-podcast-selection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c454c8..077ba028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Release confidence process, documented and executable: `.github/RELEASE_PROCESS.md` now covers the risk-based test matrix (buckets A/B/C), the Docker image gate, the fix-loop re-test policy and the communication/credits/retro structure, backed by a new decision record (ADR-005) and versioned tooling under `scripts/release-test/` — `make release-test TAG= OLD_TAG=` runs fresh-install + upgrade scenarios against real images, and `make release-stack TAG= [DUMP=]` boots a browsable, isolated release-candidate stack (optionally with a copy of dev data) for manual verification - CI now gates every PR on `ruff check` (backend lint), `npm run lint` (frontend ESLint) and `npm run build` (frontend production build), in addition to the existing test suites +### Fixed +- Podcast generation dialog: the token/char counter no longer fires a request storm on rapid checkbox toggling (debounced, with a stale-response guard so a slow response can't overwrite a fresher count) and the dialog now closes as soon as the episode-list refetch completes instead of after a fixed 500ms timer; the 983-line component was also split (content selection panel and selection helpers extracted, duplicated context-config logic deduplicated) with no behavior changes + ### Changed - Re-enabled the ruff rules for unused imports (`F401`), unused local variables (`F841`) and bare `except:` (`E722`) that were ignored to silence legacy Streamlit-era noise, and cleaned up the remaining fallout (10 unused imports, 2 unused test variables; no bare excepts remained) - Chat, source chat, Ask and transformation prompts now steer models to write math as `$$...$$` (display) / `$...$` (inline) so formulas render via KaTeX, reserving fenced `latex` code blocks for when the user explicitly asks for the LaTeX source (#1051) diff --git a/frontend/src/components/podcasts/ContentSelectionPanel.tsx b/frontend/src/components/podcasts/ContentSelectionPanel.tsx new file mode 100644 index 00000000..664a170d --- /dev/null +++ b/frontend/src/components/podcasts/ContentSelectionPanel.tsx @@ -0,0 +1,313 @@ +'use client' + +import { Loader2 } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' + +import { sourcesApi } from '@/lib/api/sources' +import { notesApi } from '@/lib/api/notes' +import { NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api' +import { QUERY_KEYS } from '@/lib/api/query-client' +import { useTranslation } from '@/lib/hooks/use-translation' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' + +import { + NotebookSelection, + NotebookSummary, + SourceMode, + formatNumber, + getSourceDefaultMode, +} from './generate-podcast-selection' + +interface ContentSelectionPanelProps { + notebooks: NotebookResponse[] + isLoading: boolean + selectedNotebookSummaries: NotebookSummary[] + tokenCount: number + charCount: number + expandedNotebooks: string[] + setExpandedNotebooks: (notebooks: string[]) => void + selections: Record + sourcesByNotebook: Record + notesByNotebook: Record + fetchingNotebookIds: Set + onNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void + onSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void + onNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void +} + +export function ContentSelectionPanel({ + notebooks, + isLoading, + selectedNotebookSummaries, + tokenCount, + charCount, + expandedNotebooks, + setExpandedNotebooks, + selections, + sourcesByNotebook, + notesByNotebook, + fetchingNotebookIds, + onNotebookToggle, + onSourceModeChange, + onNoteToggle, +}: ContentSelectionPanelProps) { + const { t, language } = useTranslation() + const queryClient = useQueryClient() + + const sourceModes = [ + { value: 'insights', label: t('podcasts.summary') }, + { value: 'full', label: t('podcasts.fullContent') }, + ] as const + + return ( +
+
+
+

+ {t('podcasts.content')} +

+

+ {t('podcasts.contentDesc')} +

+
+
+ + {t('podcasts.itemsSelected').replace( + '{count}', + selectedNotebookSummaries.reduce( + (acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes, + 0 + ).toString() + )} + + {(tokenCount > 0 || charCount > 0) && ( + + {tokenCount > 0 && t('podcasts.tokens').replace('{count}', formatNumber(tokenCount))} + {tokenCount > 0 && charCount > 0 && ' / '} + {charCount > 0 && t('podcasts.chars').replace('{count}', formatNumber(charCount))} + + )} +
+
+ +
+ {isLoading ? ( +
+ {t('podcasts.loadingNotebooks')} +
+ ) : notebooks.length === 0 ? ( +
+ {t('podcasts.noNotebooksFoundInPodcasts')} +
+ ) : ( + + setExpandedNotebooks(value as string[])} + className="w-full" + > + {notebooks.map((notebook: NotebookResponse, index: number) => { + const sources = sourcesByNotebook[notebook.id] ?? [] + const notes = notesByNotebook[notebook.id] ?? [] + const selection = selections[notebook.id] + const summary = selectedNotebookSummaries[index] + const notebookChecked = summary.sources + summary.notes > 0 + const totalItems = sources.length + notes.length + const isIndeterminate = + notebookChecked && + summary.sources + summary.notes > 0 && + summary.sources + summary.notes < totalItems + + return ( + +
+ { + onNotebookToggle(notebook.id, checked) + queryClient.prefetchQuery({ + queryKey: QUERY_KEYS.sources(notebook.id), + queryFn: () => sourcesApi.list({ notebook_id: notebook.id }), + }) + queryClient.prefetchQuery({ + queryKey: QUERY_KEYS.notes(notebook.id), + queryFn: () => notesApi.list({ notebook_id: notebook.id }), + }) + }} + onClick={(event) => event.stopPropagation()} + /> + + + +
+ +
+
+
+

+ {t('podcasts.sources')} +

+ {fetchingNotebookIds.has(notebook.id) && ( + + )} +
+ {sources.length === 0 ? ( +

+ {t('podcasts.noSources')} +

+ ) : ( +
+ {sources.map((source: SourceListResponse) => { + const mode = selection?.sources?.[source.id] ?? 'off' + return ( +
+ + onSourceModeChange( + notebook.id, + source.id, + checked ? getSourceDefaultMode(source) : 'off' + ) + } + /> + + +
+ ) + })} +
+ )} +
+ + + +
+

+ {t('podcasts.notes')} +

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

+ {t('podcasts.noNotes')} +

+ ) : ( +
+ {notes.map((note: NoteResponse) => { + const mode = selection?.notes?.[note.id] ?? 'off' + return ( +
+ + onNoteToggle( + notebook.id, + note.id, + Boolean(checked) + ) + } + /> + +
+ ) + })} +
+ )} +
+
+
+
+ ) + })} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/podcasts/GeneratePodcastDialog.tsx b/frontend/src/components/podcasts/GeneratePodcastDialog.tsx index 0f76e743..032ed7f7 100644 --- a/frontend/src/components/podcasts/GeneratePodcastDialog.tsx +++ b/frontend/src/components/podcasts/GeneratePodcastDialog.tsx @@ -1,16 +1,15 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' -import { useQueries, useQueryClient } from '@tanstack/react-query' +import { useQueries } from '@tanstack/react-query' import { useNotebooks } from '@/lib/hooks/use-notebooks' import { useEpisodeProfiles, useGeneratePodcast } from '@/lib/hooks/use-podcasts' import { chatApi } from '@/lib/api/chat' import { sourcesApi } from '@/lib/api/sources' import { notesApi } from '@/lib/api/notes' -import { BuildContextRequest, NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api' -import type { QueryClient } from '@tanstack/react-query' +import { NoteResponse, SourceListResponse } from '@/lib/types/api' import { PodcastGenerationRequest } from '@/lib/types/podcasts' import { QUERY_KEYS } from '@/lib/api/query-client' import { useToast } from '@/lib/hooks/use-toast' @@ -23,381 +22,30 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' -import { ScrollArea } from '@/components/ui/scroll-area' import { Label } from '@/components/ui/label' -import { Separator } from '@/components/ui/separator' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' -type SourceMode = 'off' | 'insights' | 'full' +import { ContentSelectionPanel } from './ContentSelectionPanel' +import { + NotebookSelection, + SourceMode, + getSourceDefaultMode, + hasSelections, + selectionsToContextConfigs, +} from './generate-podcast-selection' -interface NotebookSelection { - sources: Record - notes: Record -} - -// Helper function to format large numbers with K/M suffixes -function formatNumber(num: number): string { - if (num >= 1000000) { - return `${(num / 1000000).toFixed(1)}M` - } - if (num >= 1000) { - return `${(num / 1000).toFixed(1)}K` - } - return num.toString() -} - -function hasSelections(selection?: NotebookSelection): boolean { - if (!selection) { - return false - } - return ( - Object.values(selection.sources).some((mode) => mode !== 'off') || - Object.values(selection.notes).some((mode) => mode !== 'off') - ) -} - -function getSourceDefaultMode(source: SourceListResponse): SourceMode { - return source.insights_count && source.insights_count > 0 ? 'insights' : 'full' -} +const TOKEN_COUNT_DEBOUNCE_MS = 400 interface GeneratePodcastDialogProps { open: boolean onOpenChange: (open: boolean) => void } -interface NotebookSummary { - notebookId: string - sources: number - notes: number -} - -interface ContentSelectionPanelProps { - notebooks: NotebookResponse[] - isLoading: boolean - selectedNotebookSummaries: NotebookSummary[] - tokenCount: number - charCount: number - expandedNotebooks: string[] - setExpandedNotebooks: (notebooks: string[]) => void - selections: Record - sourcesByNotebook: Record - notesByNotebook: Record - fetchingNotebookIds: Set - handleNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void - handleSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void - handleNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void - queryClient: QueryClient -} - -// Extracted component for content selection panel -function ContentSelectionPanel({ - notebooks, - isLoading, - selectedNotebookSummaries, - tokenCount, - charCount, - expandedNotebooks, - setExpandedNotebooks, - selections, - sourcesByNotebook, - notesByNotebook, - fetchingNotebookIds, - handleNotebookToggle, - handleSourceModeChange, - handleNoteToggle, - queryClient, -}: ContentSelectionPanelProps) { - const { t, language } = useTranslation() - - // Cache all translation strings at render time to avoid repeated Proxy accesses in loops - // This prevents the infinite loop detection from triggering - const tr = { - content: t('podcasts.content'), - contentDesc: t('podcasts.contentDesc'), - itemsSelected: t('podcasts.itemsSelected'), - tokens: t('podcasts.tokens'), - chars: t('podcasts.chars'), - loadingNotebooks: t('podcasts.loadingNotebooks'), - noNotebooksFoundInPodcasts: t('podcasts.noNotebooksFoundInPodcasts'), - sources: t('podcasts.sources'), - notes: t('podcasts.notes'), - noContentSelected: t('podcasts.noContentSelected'), - noSources: t('podcasts.noSources'), - untitledSource: t('podcasts.untitledSource'), - link: t('podcasts.link'), - file: t('podcasts.file'), - embedded: t('podcasts.embedded'), - notEmbedded: t('podcasts.notEmbedded'), - selectMode: t('podcasts.selectMode'), - noNotes: t('podcasts.noNotes'), - untitledNote: t('podcasts.untitledNote'), - commonUpdated: t('common.updated'), - summary: t('podcasts.summary'), - fullContent: t('podcasts.fullContent'), - } - - // Pre-compute source modes once to avoid repeated t.podcasts access in loops - const sourceModes = [ - { value: 'insights', label: tr.summary }, - { value: 'full', label: tr.fullContent }, - ] as const - - return ( -
-
-
-

- {tr.content} -

-

- {tr.contentDesc} -

-
-
- - {tr.itemsSelected.replace( - '{count}', - selectedNotebookSummaries.reduce( - (acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes, - 0 - ).toString() - )} - - {(tokenCount > 0 || charCount > 0) && ( - - {tokenCount > 0 && tr.tokens.replace('{count}', formatNumber(tokenCount))} - {tokenCount > 0 && charCount > 0 && ' / '} - {charCount > 0 && tr.chars.replace('{count}', formatNumber(charCount))} - - )} -
-
- -
- {isLoading ? ( -
- {tr.loadingNotebooks} -
- ) : notebooks.length === 0 ? ( -
- {tr.noNotebooksFoundInPodcasts} -
- ) : ( - - setExpandedNotebooks(value as string[])} - className="w-full" - > - {notebooks.map((notebook: NotebookResponse, index: number) => { - const sources = sourcesByNotebook[notebook.id] ?? [] - const notes = notesByNotebook[notebook.id] ?? [] - const selection = selections[notebook.id] - const summary = selectedNotebookSummaries[index] - const notebookChecked = summary.sources + summary.notes > 0 - const totalItems = sources.length + notes.length - const isIndeterminate = - notebookChecked && - summary.sources + summary.notes > 0 && - summary.sources + summary.notes < totalItems - - return ( - -
- { - handleNotebookToggle(notebook.id, checked) - queryClient.prefetchQuery({ - queryKey: QUERY_KEYS.sources(notebook.id), - queryFn: () => sourcesApi.list({ notebook_id: notebook.id }), - }) - queryClient.prefetchQuery({ - queryKey: QUERY_KEYS.notes(notebook.id), - queryFn: () => notesApi.list({ notebook_id: notebook.id }), - }) - }} - onClick={(event) => event.stopPropagation()} - /> - - - -
- -
-
-
-

- {tr.sources} -

- {fetchingNotebookIds.has(notebook.id) && ( - - )} -
- {sources.length === 0 ? ( -

- {tr.noSources} -

- ) : ( -
- {sources.map((source: SourceListResponse) => { - const mode = selection?.sources?.[source.id] ?? 'off' - return ( -
- - handleSourceModeChange( - notebook.id, - source.id, - checked ? getSourceDefaultMode(source) : 'off' - ) - } - /> - - -
- ) - })} -
- )} -
- - - -
-

- {tr.notes} -

- {notes.length === 0 ? ( -

- {tr.noNotes} -

- ) : ( -
- {notes.map((note: NoteResponse) => { - const mode = selection?.notes?.[note.id] ?? 'off' - return ( -
- - handleNoteToggle( - notebook.id, - note.id, - Boolean(checked) - ) - } - /> - -
- ) - })} -
- )} -
-
-
-
- ) - })} -
-
- )} -
-
- ) -} - export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) { const { t } = useTranslation() const { toast } = useToast() - const queryClient = useQueryClient() const [expandedNotebooks, setExpandedNotebooks] = useState([]) const [selections, setSelections] = useState>({}) const [episodeProfileId, setEpisodeProfileId] = useState('') @@ -553,59 +201,42 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia } }, [open, resetState]) - // Update token/char counts when selections change + // Generation counter: any newer effect run invalidates in-flight count requests, + // so a slow, stale response can never overwrite a fresher one. + const countRequestIdRef = useRef(0) + + // Update token/char counts when selections change (debounced + stale-guarded) useEffect(() => { + const requestId = ++countRequestIdRef.current + if (!open) { return } - const updateContextCounts = async () => { - // Check if there are any selections - const hasAnySelections = Object.values(selections).some((selection) => - Object.values(selection.sources).some((mode) => mode !== 'off') || - Object.values(selection.notes).some((mode) => mode !== 'off') - ) + const configs = selectionsToContextConfigs(selections) - if (!hasAnySelections) { - setTokenCount(0) - setCharCount(0) - return - } + if (configs.length === 0) { + setTokenCount(0) + setCharCount(0) + return + } + const timer = setTimeout(async () => { try { let totalTokens = 0 let totalChars = 0 // Build context for each notebook and sum up counts - for (const [notebookId, selection] of Object.entries(selections)) { - const sourcesConfig = Object.entries(selection.sources) - .filter(([, mode]) => mode !== 'off') - .reduce>((acc, [sourceId, mode]) => { - const normalizedId = sourceId.replace(/^source:/, '') - acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content' - return acc - }, {}) - - const notesConfig = Object.entries(selection.notes) - .filter(([, mode]) => mode !== 'off') - .reduce>((acc, [noteId]) => { - const normalizedId = noteId.replace(/^note:/, '') - acc[normalizedId] = 'full content' - return acc - }, {}) - - if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) { - continue - } - + for (const { notebookId, contextConfig } of configs) { const response = await chatApi.buildContext({ notebook_id: notebookId, - context_config: { - sources: sourcesConfig, - notes: notesConfig, - }, + context_config: contextConfig, }) + if (requestId !== countRequestIdRef.current) { + return + } + totalTokens += response.token_count totalChars += response.char_count } @@ -613,12 +244,14 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia setTokenCount(totalTokens) setCharCount(totalChars) } catch (error) { - console.error('Error updating context counts:', error) - // Don't reset counts on error, keep previous values + if (requestId === countRequestIdRef.current) { + console.error('Error updating context counts:', error) + // Don't reset counts on error, keep previous values + } } - } + }, TOKEN_COUNT_DEBOUNCE_MS) - updateContextCounts() + return () => clearTimeout(timer) }, [open, selections]) const selectedEpisodeProfile = useMemo(() => { @@ -724,40 +357,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia const buildContentFromSelections = useCallback(async () => { const parts: string[] = [] - const tasks: Array<{ notebookId: string; payload: BuildContextRequest }> = [] - - Object.entries(selections).forEach(([notebookId, selection]) => { - const sourcesConfig = Object.entries(selection.sources) - .filter(([, mode]) => mode !== 'off') - .reduce>((acc, [sourceId, mode]) => { - const normalizedId = sourceId.replace(/^source:/, '') - acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content' - return acc - }, {}) - - const notesConfig = Object.entries(selection.notes) - .filter(([, mode]) => mode !== 'off') - .reduce>((acc, [noteId]) => { - const normalizedId = noteId.replace(/^note:/, '') - acc[normalizedId] = 'full content' - return acc - }, {}) - - if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) { - return - } - - tasks.push({ - notebookId, - payload: { - notebook_id: notebookId, - context_config: { - sources: sourcesConfig, - notes: notesConfig, - }, - }, - }) - }) + const tasks = selectionsToContextConfigs(selections) if (tasks.length === 0) { return '' @@ -765,7 +365,10 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia for (const task of tasks) { try { - const response = await chatApi.buildContext(task.payload) + const response = await chatApi.buildContext({ + notebook_id: task.notebookId, + context_config: task.contextConfig, + }) const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId const contextString = JSON.stringify(response.context, null, 2) const snippet = `${t('common.notebookLabel').replace('{name}', notebookName)}\n${contextString}` @@ -818,6 +421,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia briefing_suffix: instructions.trim() ? instructions.trim() : undefined, } + // mutateAsync resolves only after the mutation's onSuccess handler has + // awaited the episode list refetch, so it is safe to close immediately. await generatePodcast.mutateAsync(payload) toast({ @@ -825,11 +430,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia description: t('podcasts.podcastTaskStarted'), }) - // Delay closing dialog slightly to ensure refetch completes - setTimeout(() => { - onOpenChange(false) - resetState() - }, 500) + onOpenChange(false) + resetState() } catch (error) { console.error('Failed to generate podcast', error) toast({ @@ -882,10 +484,9 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia sourcesByNotebook={sourcesByNotebook} notesByNotebook={notesByNotebook} fetchingNotebookIds={fetchingNotebookIds} - handleNotebookToggle={handleNotebookToggle} - handleSourceModeChange={handleSourceModeChange} - handleNoteToggle={handleNoteToggle} - queryClient={queryClient} + onNotebookToggle={handleNotebookToggle} + onSourceModeChange={handleSourceModeChange} + onNoteToggle={handleNoteToggle} />
diff --git a/frontend/src/components/podcasts/generate-podcast-selection.ts b/frontend/src/components/podcasts/generate-podcast-selection.ts new file mode 100644 index 00000000..15deebe3 --- /dev/null +++ b/frontend/src/components/podcasts/generate-podcast-selection.ts @@ -0,0 +1,89 @@ +import { SourceListResponse } from '@/lib/types/api' + +export type SourceMode = 'off' | 'insights' | 'full' + +export interface NotebookSelection { + sources: Record + notes: Record +} + +export interface NotebookSummary { + notebookId: string + sources: number + notes: number +} + +export interface NotebookContextConfig { + notebookId: string + contextConfig: { + sources: Record + notes: Record + } +} + +// Helper function to format large numbers with K/M suffixes +export function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M` + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K` + } + return num.toString() +} + +export function hasSelections(selection?: NotebookSelection): boolean { + if (!selection) { + return false + } + return ( + Object.values(selection.sources).some((mode) => mode !== 'off') || + Object.values(selection.notes).some((mode) => mode !== 'off') + ) +} + +export function getSourceDefaultMode(source: SourceListResponse): SourceMode { + return source.insights_count && source.insights_count > 0 ? 'insights' : 'full' +} + +/** + * Convert the per-notebook selection state into build-context configs, + * skipping notebooks with no active selections. + */ +export function selectionsToContextConfigs( + selections: Record +): NotebookContextConfig[] { + const configs: NotebookContextConfig[] = [] + + Object.entries(selections).forEach(([notebookId, selection]) => { + const sourcesConfig = Object.entries(selection.sources) + .filter(([, mode]) => mode !== 'off') + .reduce>((acc, [sourceId, mode]) => { + const normalizedId = sourceId.replace(/^source:/, '') + acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content' + return acc + }, {}) + + const notesConfig = Object.entries(selection.notes) + .filter(([, mode]) => mode !== 'off') + .reduce>((acc, [noteId]) => { + const normalizedId = noteId.replace(/^note:/, '') + acc[normalizedId] = 'full content' + return acc + }, {}) + + if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) { + return + } + + configs.push({ + notebookId, + contextConfig: { + sources: sourcesConfig, + notes: notesConfig, + }, + }) + }) + + return configs +}