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.
This commit is contained in:
Luis Novo 2026-07-11 18:47:27 -03:00 committed by GitHub
parent 12ced05b3a
commit 2bd9211abc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 455 additions and 449 deletions

View file

@ -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)

View file

@ -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<string, NotebookSelection>
sourcesByNotebook: Record<string, SourceListResponse[]>
notesByNotebook: Record<string, NoteResponse[]>
fetchingNotebookIds: Set<string>
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('podcasts.content')}
</h3>
<p className="text-xs text-muted-foreground">
{t('podcasts.contentDesc')}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
{t('podcasts.itemsSelected').replace(
'{count}',
selectedNotebookSummaries.reduce(
(acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,
0
).toString()
)}
</Badge>
{(tokenCount > 0 || charCount > 0) && (
<span className="text-xs text-muted-foreground">
{tokenCount > 0 && t('podcasts.tokens').replace('{count}', formatNumber(tokenCount))}
{tokenCount > 0 && charCount > 0 && ' / '}
{charCount > 0 && t('podcasts.chars').replace('{count}', formatNumber(charCount))}
</span>
)}
</div>
</div>
<div className="rounded-lg border bg-muted/30">
{isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('podcasts.loadingNotebooks')}
</div>
) : notebooks.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground">
{t('podcasts.noNotebooksFoundInPodcasts')}
</div>
) : (
<ScrollArea className="h-[60vh]">
<Accordion
type="multiple"
value={expandedNotebooks}
onValueChange={(value) => 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 (
<AccordionItem key={notebook.id} value={notebook.id}>
<div className="flex items-start gap-3 px-4 pt-3">
<Checkbox
id={`notebook-toggle-${notebook.id}`}
checked={isIndeterminate ? 'indeterminate' : notebookChecked}
onCheckedChange={(checked) => {
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()}
/>
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
<Label
htmlFor={`notebook-toggle-${notebook.id}`}
className="flex w-full items-center justify-between gap-3 pointer-events-none"
>
<div className="text-left">
<p className="font-medium text-sm text-foreground">
{notebook.name}
</p>
<p className="text-xs text-muted-foreground">
{summary.sources + summary.notes > 0
? `${summary.sources} ${t('podcasts.sources')}, ${summary.notes} ${t('podcasts.notes')}`
: t('podcasts.noContentSelected')}
</p>
</div>
<Badge variant="outline" className="text-xs">
{sources.length} {t('podcasts.sources')} · {notes.length} {t('podcasts.notes')}
</Badge>
</Label>
</AccordionTrigger>
</div>
<AccordionContent>
<div className="space-y-4 px-4 pb-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('podcasts.sources')}
</h4>
{fetchingNotebookIds.has(notebook.id) && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
{sources.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t('podcasts.noSources')}
</p>
) : (
<div className="space-y-2">
{sources.map((source: SourceListResponse) => {
const mode = selection?.sources?.[source.id] ?? 'off'
return (
<div
key={source.id}
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
>
<Checkbox
id={`source-selection-${source.id}`}
checked={mode !== 'off'}
onCheckedChange={(checked) =>
onSourceModeChange(
notebook.id,
source.id,
checked ? getSourceDefaultMode(source) : 'off'
)
}
/>
<Label
htmlFor={`source-selection-${source.id}`}
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<span className="text-sm font-medium text-foreground">
{source.title || t('podcasts.untitledSource')}
</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{source.asset?.url ? t('podcasts.link') : t('podcasts.file')}</span>
<span></span>
<span>{source.embedded ? t('podcasts.embedded') : t('podcasts.notEmbedded')}</span>
</div>
</Label>
<Select
value={mode === 'off' ? 'off' : mode}
onValueChange={(value) =>
onSourceModeChange(
notebook.id,
source.id,
value as SourceMode
)
}
disabled={mode === 'off'}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder={t('podcasts.selectMode')} />
</SelectTrigger>
<SelectContent>
{sourceModes.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={
option.value === 'insights' &&
(!source.insights_count || source.insights_count === 0)
}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('podcasts.notes')}
</h4>
{notes.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t('podcasts.noNotes')}
</p>
) : (
<div className="space-y-2">
{notes.map((note: NoteResponse) => {
const mode = selection?.notes?.[note.id] ?? 'off'
return (
<div
key={note.id}
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
>
<Checkbox
id={`note-selection-${note.id}`}
checked={mode !== 'off'}
onCheckedChange={(checked) =>
onNoteToggle(
notebook.id,
note.id,
Boolean(checked)
)
}
/>
<Label
htmlFor={`note-selection-${note.id}`}
className="flex flex-1 flex-col cursor-pointer"
>
<span className="text-sm font-medium text-foreground">
{note.title || t('podcasts.untitledNote')}
</span>
<span className="text-xs text-muted-foreground">
{t('common.updated')}{' '}
{new Date(note.updated).toLocaleString(
language.startsWith('zh') ? language : 'en-US'
)}
</span>
</Label>
</div>
)
})}
</div>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</ScrollArea>
)}
</div>
</div>
)
}

View file

@ -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<string, SourceMode>
notes: Record<string, SourceMode>
}
// 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<string, NotebookSelection>
sourcesByNotebook: Record<string, SourceListResponse[]>
notesByNotebook: Record<string, NoteResponse[]>
fetchingNotebookIds: Set<string>
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{tr.content}
</h3>
<p className="text-xs text-muted-foreground">
{tr.contentDesc}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
{tr.itemsSelected.replace(
'{count}',
selectedNotebookSummaries.reduce(
(acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,
0
).toString()
)}
</Badge>
{(tokenCount > 0 || charCount > 0) && (
<span className="text-xs text-muted-foreground">
{tokenCount > 0 && tr.tokens.replace('{count}', formatNumber(tokenCount))}
{tokenCount > 0 && charCount > 0 && ' / '}
{charCount > 0 && tr.chars.replace('{count}', formatNumber(charCount))}
</span>
)}
</div>
</div>
<div className="rounded-lg border bg-muted/30">
{isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {tr.loadingNotebooks}
</div>
) : notebooks.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground">
{tr.noNotebooksFoundInPodcasts}
</div>
) : (
<ScrollArea className="h-[60vh]">
<Accordion
type="multiple"
value={expandedNotebooks}
onValueChange={(value) => 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 (
<AccordionItem key={notebook.id} value={notebook.id}>
<div className="flex items-start gap-3 px-4 pt-3">
<Checkbox
id={`notebook-toggle-${notebook.id}`}
checked={isIndeterminate ? 'indeterminate' : notebookChecked}
onCheckedChange={(checked) => {
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()}
/>
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
<Label
htmlFor={`notebook-toggle-${notebook.id}`}
className="flex w-full items-center justify-between gap-3 pointer-events-none"
>
<div className="text-left">
<p className="font-medium text-sm text-foreground">
{notebook.name}
</p>
<p className="text-xs text-muted-foreground">
{summary.sources + summary.notes > 0
? `${summary.sources} ${tr.sources}, ${summary.notes} ${tr.notes}`
: tr.noContentSelected}
</p>
</div>
<Badge variant="outline" className="text-xs">
{sources.length} {tr.sources} · {notes.length} {tr.notes}
</Badge>
</Label>
</AccordionTrigger>
</div>
<AccordionContent>
<div className="space-y-4 px-4 pb-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{tr.sources}
</h4>
{fetchingNotebookIds.has(notebook.id) && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
{sources.length === 0 ? (
<p className="text-xs text-muted-foreground">
{tr.noSources}
</p>
) : (
<div className="space-y-2">
{sources.map((source: SourceListResponse) => {
const mode = selection?.sources?.[source.id] ?? 'off'
return (
<div
key={source.id}
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
>
<Checkbox
id={`source-selection-${source.id}`}
checked={mode !== 'off'}
onCheckedChange={(checked) =>
handleSourceModeChange(
notebook.id,
source.id,
checked ? getSourceDefaultMode(source) : 'off'
)
}
/>
<Label
htmlFor={`source-selection-${source.id}`}
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<span className="text-sm font-medium text-foreground">
{source.title || tr.untitledSource}
</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{source.asset?.url ? tr.link : tr.file}</span>
<span></span>
<span>{source.embedded ? tr.embedded : tr.notEmbedded}</span>
</div>
</Label>
<Select
value={mode === 'off' ? 'off' : mode}
onValueChange={(value) =>
handleSourceModeChange(
notebook.id,
source.id,
value as SourceMode
)
}
disabled={mode === 'off'}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder={tr.selectMode} />
</SelectTrigger>
<SelectContent>
{sourceModes.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={
option.value === 'insights' &&
(!source.insights_count || source.insights_count === 0)
}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{tr.notes}
</h4>
{notes.length === 0 ? (
<p className="text-xs text-muted-foreground">
{tr.noNotes}
</p>
) : (
<div className="space-y-2">
{notes.map((note: NoteResponse) => {
const mode = selection?.notes?.[note.id] ?? 'off'
return (
<div
key={note.id}
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
>
<Checkbox
id={`note-selection-${note.id}`}
checked={mode !== 'off'}
onCheckedChange={(checked) =>
handleNoteToggle(
notebook.id,
note.id,
Boolean(checked)
)
}
/>
<Label
htmlFor={`note-selection-${note.id}`}
className="flex flex-1 flex-col cursor-pointer"
>
<span className="text-sm font-medium text-foreground">
{note.title || tr.untitledNote}
</span>
<span className="text-xs text-muted-foreground">
{tr.commonUpdated}{' '}
{new Date(note.updated).toLocaleString(
language.startsWith('zh') ? language : 'en-US'
)}
</span>
</Label>
</div>
)
})}
</div>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</ScrollArea>
)}
</div>
</div>
)
}
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
const { t } = useTranslation()
const { toast } = useToast()
const queryClient = useQueryClient()
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])
const [selections, setSelections] = useState<Record<string, NotebookSelection>>({})
const [episodeProfileId, setEpisodeProfileId] = useState<string>('')
@ -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<Record<string, string>>((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<Record<string, string>>((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<Record<string, string>>((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<Record<string, string>>((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}
/>
<div className="space-y-6">

View file

@ -0,0 +1,89 @@
import { SourceListResponse } from '@/lib/types/api'
export type SourceMode = 'off' | 'insights' | 'full'
export interface NotebookSelection {
sources: Record<string, SourceMode>
notes: Record<string, SourceMode>
}
export interface NotebookSummary {
notebookId: string
sources: number
notes: number
}
export interface NotebookContextConfig {
notebookId: string
contextConfig: {
sources: Record<string, string>
notes: Record<string, string>
}
}
// 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<string, NotebookSelection>
): NotebookContextConfig[] {
const configs: NotebookContextConfig[] = []
Object.entries(selections).forEach(([notebookId, selection]) => {
const sourcesConfig = Object.entries(selection.sources)
.filter(([, mode]) => mode !== 'off')
.reduce<Record<string, string>>((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<Record<string, string>>((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
}