import { useState, useEffect, useRef } from 'react' import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api' import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils' const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' } // Provider labels are display-only; the values are the provider_kind strings // the server persists in entry_summaries.provider_kind. const SUMMARY_PROVIDERS = [ { value: 'anthropic_http', label: 'Anthropic API' }, { value: 'openai_compatible', label: 'OpenAI-compatible API' }, { value: 'claude_cli', label: 'Claude CLI' }, { value: 'codex_cli', label: 'Codex CLI' }, ] const PROVIDER_LABEL = Object.fromEntries(SUMMARY_PROVIDERS.map(p => [p.value, p.label])) const SUMMARY_PROVIDER_KEY = 'archivr:summary:provider' const SUMMARY_POLL_MS = 1500 // Summaries are stored as the raw JSON string the model produced (normalized // server-side to {tldr, summary, tags}). Parsing can still fail for rows written // by an older prompt version, so fall back to showing the text as-is rather than // hiding a summary the user can perfectly well read. function parseSummaryText(text) { if (!text) return null try { const parsed = JSON.parse(text) if (parsed && typeof parsed === 'object') { return { tldr: typeof parsed.tldr === 'string' ? parsed.tldr : '', summary: typeof parsed.summary === 'string' ? parsed.summary : '', tags: Array.isArray(parsed.tags) ? parsed.tags.filter(t => typeof t === 'string') : [], } } } catch { /* not JSON — fall through */ } return { tldr: '', summary: text, tags: [] } } const ExternalIcon = () => ( ) export default function ContextRail({ archiveId, selectedEntry, selectedUids, selectedEntries, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, onBulkDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay, isPublicSession }) { const [tags, setTags] = useState([]) const [assignInput, setAssignInput] = useState('') const [entryCollections, setEntryCollections] = useState([]) const [assignError, setAssignError] = useState('') const selectSeqRef = useRef(0) const titleCancelRef = useRef(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') const [rearchiveState, setRearchiveState] = useState('idle') // 'idle' | 'running' | 'done' | 'error' const [rearchiveError, setRearchiveError] = useState('') const rearchivePollRef = useRef(null) const [fontsOpen, setFontsOpen] = useState(false) useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid]) // ── Summary state ─────────────────────────────────────────────────────── // `summary` mirrors the server row. It is seeded from detail.latest_summary so // the section renders immediately on selection, then kept fresh by polling // only while a job is non-terminal. const [summary, setSummary] = useState(null) const [summaryError, setSummaryError] = useState('') const [summaryBusy, setSummaryBusy] = useState(false) const [summaryProvider, setSummaryProvider] = useState(() => { try { return sessionStorage.getItem(SUMMARY_PROVIDER_KEY) || SUMMARY_PROVIDERS[0].value } catch { return SUMMARY_PROVIDERS[0].value } }) const summaryPollRef = useRef(null) // ── Bulk-panel state ──────────────────────────────────────────────────── const isBulk = selectedUids?.size >= 2 const [bulkTagInput, setBulkTagInput] = useState('') const [bulkTagState, setBulkTagState] = useState('idle') // 'idle'|'running'|'done'|'error' const [bulkTagError, setBulkTagError] = useState('') const [collections, setCollections] = useState([]) const [bulkCollUid, setBulkCollUid] = useState('') const [bulkCollState, setBulkCollState] = useState('idle') // 'idle'|'running'|'done'|'error' const [bulkCollError, setBulkCollError] = useState('') const [bulkDeleteState, setBulkDeleteState] = useState('idle') // 'idle'|'running' const [singleCollUid, setSingleCollUid] = useState('') const [singleCollState, setSingleCollState] = useState('idle') const [singleCollError, setSingleCollError] = useState('') useEffect(() => { const seq = ++selectSeqRef.current if (rearchivePollRef.current) { clearInterval(rearchivePollRef.current); rearchivePollRef.current = null } setRearchiveState('idle') setRearchiveError('') if (!selectedEntry || !archiveId) { setTags([]) setEntryCollections([]) return } // Skip auth-required tag/collection fetches for public guests. if (isPublicSession) { setTags([]) setEntryCollections([]) return } setEditingTitle(false) setTitleDraft('') titleCancelRef.current = false setTags([]) Promise.all([ fetchEntryTags(archiveId, selectedEntry.entry_uid), listEntryCollections(archiveId, selectedEntry.entry_uid), ]).then(([tgs, ecs]) => { if (seq !== selectSeqRef.current) return setTags(tgs) setEntryCollections(ecs) }).catch(() => {}) }, [selectedEntry, archiveId, isPublicSession]) useEffect(() => { return () => { clearInterval(rearchivePollRef.current) } }, []) // Seed the summary from the entry detail payload and stop any poll left over // from the previously selected entry. useEffect(() => { clearInterval(summaryPollRef.current) summaryPollRef.current = null setSummary(detail?.latest_summary ?? null) setSummaryError('') setSummaryBusy(false) }, [detail?.summary?.entry_uid]) // Poll only while the latest summary is non-terminal. Anchoring the effect on // the status (rather than starting a timer inside the click handler) means a // job still running when the user navigates away and back is picked up again. const summaryStatus = summary?.status useEffect(() => { clearInterval(summaryPollRef.current) summaryPollRef.current = null if (summaryStatus !== 'pending' && summaryStatus !== 'running') return if (!archiveId || !detail?.summary?.entry_uid) return const entryUid = detail.summary.entry_uid summaryPollRef.current = setInterval(async () => { try { const res = await fetchEntrySummary(archiveId, entryUid) setSummary(res.summary ?? null) const st = res.summary?.status if (st !== 'pending' && st !== 'running') { clearInterval(summaryPollRef.current) summaryPollRef.current = null setSummaryBusy(false) if (st === 'completed') onDetailRefresh?.() } } catch { // A transient poll failure is not worth tearing the section down; the // next tick retries, and a real failure lands as status === 'failed'. } }, SUMMARY_POLL_MS) return () => { clearInterval(summaryPollRef.current) summaryPollRef.current = null } }, [summaryStatus, archiveId, detail?.summary?.entry_uid]) useEffect(() => () => clearInterval(summaryPollRef.current), []) async function handleGenerateSummary(force = false) { if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return setSummaryBusy(true) setSummaryError('') try { const res = await requestEntrySummary(archiveId, detail.summary.entry_uid, { provider: summaryProvider, force, }) if (res.status === 'completed') { // 200 cache hit: the response *is* the row, no polling needed. setSummary(res) setSummaryBusy(false) onDetailRefresh?.() } else { // 202: seed a local pending row so the poll effect starts immediately // rather than waiting a tick for the first GET. setSummary({ ...(res ?? {}), status: 'pending' }) } } catch (e) { setSummaryError(e.message || 'Summary request failed') setSummaryBusy(false) } } function handleProviderChange(value) { setSummaryProvider(value) try { sessionStorage.setItem(SUMMARY_PROVIDER_KEY, value) } catch { /* private mode */ } } // Fetch available collections whenever archiveId is available useEffect(() => { if (!archiveId) { setCollections([]); return } listCollections(archiveId).then(setCollections).catch(() => setCollections([])) }, [archiveId]) // Reset transient bulk state when selection changes useEffect(() => { setBulkTagInput('') setBulkTagState('idle') setBulkTagError('') setBulkCollUid('') setBulkCollState('idle') setBulkCollError('') setBulkDeleteState('idle') setSingleCollUid('') setSingleCollState('idle') setSingleCollError('') }, [selectedUids]) async function handleBulkDelete() { const n = selectedUids.size if (!window.confirm(`Delete ${n} entr${n === 1 ? 'y' : 'ies'}? This cannot be undone.`)) return setBulkDeleteState('running') const deletedUids = new Set() for (const uid of selectedUids) { try { await deleteEntry(archiveId, uid) deletedUids.add(uid) } catch { // partial failure — skip and continue } } setBulkDeleteState('idle') onBulkDeleted?.(deletedUids) } async function handleBulkTag() { const path = bulkTagInput.trim() if (!path) return setBulkTagState('running') setBulkTagError('') try { for (const uid of selectedUids) { await assignTag(archiveId, uid, path) } setBulkTagInput('') setBulkTagState('done') onTagsRefresh?.() setTimeout(() => setBulkTagState('idle'), 1800) } catch (err) { setBulkTagError(err.message) setBulkTagState('error') } } async function handleBulkAddToCollection() { if (!bulkCollUid) return setBulkCollState('running') setBulkCollError('') const failed = [] const coll = collections.find(c => c.collection_uid === bulkCollUid) for (const uid of selectedUids) { try { await addEntryToCollection(archiveId, bulkCollUid, uid, coll?.default_visibility_bits ?? 2) } catch (err) { failed.push(uid) } } if (failed.length > 0) { setBulkCollError(`Failed for ${failed.length} entr${failed.length === 1 ? 'y' : 'ies'}.`) setBulkCollState('error') } else { setBulkCollState('done') setTimeout(() => setBulkCollState('idle'), 1800) } } async function handleSingleAddToCollection() { if (!singleCollUid || !selectedEntry) return setSingleCollState('running') setSingleCollError('') const coll = collections.find(c => c.collection_uid === singleCollUid) try { await addEntryToCollection(archiveId, singleCollUid, selectedEntry.entry_uid, coll?.default_visibility_bits ?? 2) setSingleCollState('done') setSingleCollUid('') // Refresh collection membership list const updated = await listEntryCollections(archiveId, selectedEntry.entry_uid) setEntryCollections(updated) setTimeout(() => setSingleCollState('idle'), 1800) } catch (err) { setSingleCollError(err.message) setSingleCollState('error') } } async function handleTitleSave() { const newTitle = titleDraft.trim() || null try { await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle) onEntryTitleChange?.(selectedEntry.entry_uid, newTitle) } catch { // silently revert } finally { setEditingTitle(false) } } async function handleAssignTag() { const path = assignInput.trim() if (!path || !selectedEntry) return try { await assignTag(archiveId, selectedEntry.entry_uid, path) setAssignInput('') setAssignError('') const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid) setTags(updated) onTagsRefresh() } catch (e) { setAssignError(e.message) } } async function handleRemoveTag(tagUid) { try { await removeTag(archiveId, selectedEntry.entry_uid, tagUid) const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid) setTags(updated) onTagsRefresh() } catch { // silently ignore } } async function handleDeleteEntry() { if (!selectedEntry || !archiveId) return if (!window.confirm('Delete this entry? This cannot be undone.')) return try { await deleteEntry(archiveId, selectedEntry.entry_uid) onEntryDeleted?.(selectedEntry.entry_uid) } catch { // silently ignore — entry stays selected if delete failed } } async function handleRearchive() { if (!selectedEntry || !archiveId || rearchiveState === 'running') return // Capture identity at start so closure comparisons are stable const startSeq = selectSeqRef.current const entryUid = selectedEntry.entry_uid setRearchiveState('running') setRearchiveError('') try { const { job_uid } = await rearchiveEntry(archiveId, entryUid) // If selection changed while waiting for the kick-off response, bail. if (selectSeqRef.current !== startSeq) return rearchivePollRef.current = setInterval(async () => { try { const job = await pollCaptureJob(archiveId, job_uid) if (job.status === 'completed') { clearInterval(rearchivePollRef.current) rearchivePollRef.current = null if (selectSeqRef.current !== startSeq) return setRearchiveState('done') const updated = await fetchEntryTags(archiveId, entryUid) if (selectSeqRef.current !== startSeq) return setTags(updated) onDetailRefresh?.() } else if (job.status === 'failed') { clearInterval(rearchivePollRef.current) rearchivePollRef.current = null if (selectSeqRef.current !== startSeq) return setRearchiveState('error') setRearchiveError(job.error_text || 'Re-archive failed.') } } catch { clearInterval(rearchivePollRef.current) rearchivePollRef.current = null if (selectSeqRef.current !== startSeq) return setRearchiveState('error') setRearchiveError('Network error while polling.') } }, 500) } catch (e) { if (selectSeqRef.current !== startSeq) return setRearchiveState('error') setRearchiveError(e.message || 'Failed to start re-archive.') } } const metaRows = detail ? [ ['Added', formatTimestamp(detail.summary.archived_at)], ['Source', detail.summary.source_kind], ['Type', detail.summary.entity_kind], ['Visibility', VIS_LABEL[detail.summary.visibility] ?? detail.summary.visibility], ['Root', detail.structured_root_relpath], ] : [] const AUDIO_EXTS = new Set(['mp3','ogg','m4a','opus','wav','flac','aac']) const PREVIEW_EXTS = new Set(['mp4','webm','mov','mkv','avi','m4v','ogv','pdf','html','htm','jpg','jpeg','png','gif','webp','avif','svg','bmp']) const primaryMediaIdx = detail ? detail.artifacts.findIndex(a => a.artifact_role === 'primary_media') : -1 const primaryMedia = primaryMediaIdx >= 0 ? detail.artifacts[primaryMediaIdx] : null const pmExt = primaryMedia ? primaryMedia.relpath.split('.').pop().toLowerCase() : '' const isAudio = primaryMedia && AUDIO_EXTS.has(pmExt) const primaryMediaUrl = (primaryMediaIdx >= 0 && selectedEntry) ? `/api/archives/${archiveId}/entries/${selectedEntry.entry_uid}/artifacts/${primaryMediaIdx}` : null const isPreviewable = detail && !isAudio && ( (detail.summary.entity_kind === 'tweet' || detail.summary.entity_kind === 'tweet_thread') || (primaryMedia && PREVIEW_EXTS.has(pmExt)) ) return ( ) }