import { useEffect, useRef, useState, type ReactElement } from 'react' import type { ResearchTopic, ResearchTopicWrite } from '../research-topic.ts' import type { GraphViewInjected } from './GraphView.tsx' import type { SessionGraphKey } from './locales.ts' import { loadLayout, saveLayout, type LayoutState } from './layout-store.ts' import { TopicGraph, type TopicGraphContext } from './TopicGraph.tsx' import styles from './GraphView.module.css' import { useKnowledge } from './Knowledge.tsx' import { loadWorkingPosition, saveWorkingPosition, workingPositionKey } from './working-position.ts' type Translate = (key: SessionGraphKey, params?: Record) => string /** Owns drafts and durable writes while the topic collection is open. */ export function ResearchTopics({ api, context, add, refresh = 0, t }: { readonly api: GraphViewInjected['topics'] readonly context?: TopicGraphContext readonly add?: { readonly sessionId: string; readonly done: () => void } readonly refresh?: number readonly t: Translate }): ReactElement { const knowledge = useKnowledge() const [items, setItems] = useState([]) const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading') const [revision, setRevision] = useState(0) const [remembered] = useState(() => loadWorkingPosition(context?.workingKey).topicId) const [selectedId, setSelectedId] = useState(remembered ?? '') const [unavailable, setUnavailable] = useState(false) const [title, setTitle] = useState('') const [renames, setRenames] = useState>({}) const [arrangements, setArrangements] = useState>({}) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) const createAttempt = useRef<{ readonly topicId: string; readonly title: string; readonly rename: boolean }>() const writeController = useRef() useEffect(() => () => { writeController.current?.abort() }, []) useEffect(() => { const controller = new AbortController() setPhase('loading') void api.list(controller.signal).then(value => { if (controller.signal.aborted) return setItems(value) setSelectedId(current => { if (value.some(topic => topic.topicId === current)) return current if (current !== '' || remembered !== undefined) { setUnavailable(true) return '' } return value[0]?.topicId ?? '' }) setPhase('ready') }, () => { if (!controller.signal.aborted) setPhase('error') }) return () => { controller.abort() } }, [api, revision, refresh]) useEffect(() => { if (phase === 'ready') saveWorkingPosition(context?.workingKey, { topicId: selectedId }) }, [context?.workingKey, phase, selectedId]) const write = async (request: ResearchTopicWrite): Promise => { const controller = new AbortController() writeController.current = controller setBusy(true) setFailed(false) try { let topic = await api.write(request, controller.signal) if (controller.signal.aborted) return undefined if (request.kind === 'create' && createAttempt.current?.rename && topic.title !== request.title) { topic = await api.write({ kind: 'rename', topicId: topic.topicId, title: request.title }, controller.signal) if (controller.signal.aborted) return undefined } setItems(current => current.some(item => item.topicId === topic.topicId) ? current.map(item => item.topicId === topic.topicId ? topic : item) : [...current, topic]) return topic } catch { if (!controller.signal.aborted) setFailed(true) return undefined } finally { if (!controller.signal.aborted) setBusy(false) } } const selected = items.find(topic => topic.topicId === selectedId) const arrangementKey = context === undefined ? undefined : workingPositionKey(context.actions.hostId, context.workingKey, selectedId) const draftArrangement = arrangements[selectedId] ?? (arrangementKey === undefined ? undefined : loadLayout(arrangementKey)) const selectTopic = knowledge?.selectTopic useEffect(() => { if (context === undefined) return selectTopic?.(selected?.topicId) return () => { selectTopic?.(undefined) } }, [context === undefined, selectTopic, selected?.topicId]) return

{t('topic.description')}

{unavailable ?

{t('position.topicUnavailable')}

: null} {phase === 'loading' ?

{t('topic.loading')}

: phase === 'error' ?
{t('topic.readError')}
: <>
{ event.preventDefault() const previous = createAttempt.current const name = title.trim() createAttempt.current = { topicId: previous?.topicId ?? crypto.randomUUID(), title: name, // An edited retry may have saved even if its response failed again. rename: previous !== undefined && (previous.rename || previous.title !== name), } void write({ kind: 'create', topicId: createAttempt.current.topicId, title: name }).then(topic => { if (topic === undefined) return createAttempt.current = undefined setTitle('') setSelectedId(topic.topicId) }) }}>
{items.length === 0 ?

{t('topic.empty')}

: } {selected === undefined ? null : add !== undefined ?
:
{ event.preventDefault() void write({ kind: 'rename', topicId: selected.topicId, title: renames[selected.topicId] ?? selected.title }) }}>
} } {busy ?

{t('topic.saving')}

: null} {failed ?

{t('topic.saveError')}

: null} {context === undefined || selected === undefined ? null :
{t(draftArrangement === undefined ? 'topic.arrangementHint' : 'topic.unsaved')}
} {phase !== 'ready' || selected === undefined || context === undefined ? null : { if (arrangementKey !== undefined) saveLayout(arrangementKey, state) setArrangements(current => ({ ...current, [selected.topicId]: state })) }} remove={sessionId => { void write({ kind: 'remove', topicId: selected.topicId, sessionId }) }} busy={busy} t={t} />}
}