Add toast notifications and update UI for LibraryManager.jsx
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
function statusLabel(job) {
|
||||
if (!job) return null
|
||||
const type = job.type === 'prepare' ? 'prepare' : job.type
|
||||
const progress = typeof job.progress === 'number' ? `${job.progress.toFixed(0)}%` : null
|
||||
const detail = job.detail ? ` ${job.detail}` : ''
|
||||
return `${type} · ${job.status}${progress ? ` · ${progress}` : ''}${detail}`
|
||||
const TOAST_DURATION_MS = {
|
||||
info: 3600,
|
||||
success: 4800,
|
||||
warning: 5600
|
||||
}
|
||||
|
||||
function fileSyncMeta(file) {
|
||||
@@ -61,12 +59,50 @@ export default function LibraryManager({
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [toasts, setToasts] = useState([])
|
||||
const toastTimeoutsRef = useRef(new Map())
|
||||
const toastIdRef = useRef(0)
|
||||
const previousLibraryStateRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmDelete(false)
|
||||
setErrorMessage('')
|
||||
}, [library?.slug, library?.name])
|
||||
|
||||
function dismissToast(id) {
|
||||
const timeoutId = toastTimeoutsRef.current.get(id)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
toastTimeoutsRef.current.delete(id)
|
||||
}
|
||||
setToasts(current => current.filter(toast => toast.id !== id))
|
||||
}
|
||||
|
||||
function clearToasts() {
|
||||
toastTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId))
|
||||
toastTimeoutsRef.current.clear()
|
||||
setToasts([])
|
||||
}
|
||||
|
||||
function queueToast(message, tone = 'info') {
|
||||
setToasts(current => {
|
||||
if (current.some(toast => toast.message === message && toast.tone === tone)) {
|
||||
return current
|
||||
}
|
||||
|
||||
const id = `library-toast-${toastIdRef.current++}`
|
||||
const next = [...current, { id, message, tone }].slice(-3)
|
||||
const timeoutId = window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS[tone] || TOAST_DURATION_MS.info)
|
||||
toastTimeoutsRef.current.set(id, timeoutId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
toastTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId))
|
||||
toastTimeoutsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
async function expectOk(response) {
|
||||
if (response.ok) return response
|
||||
const detail = await response.text()
|
||||
@@ -171,139 +207,181 @@ export default function LibraryManager({
|
||||
const isReadyForChat = !!library.states?.is_indexed
|
||||
const hasFailedFiles = (library.files || []).some(file => file?.sync?.status === 'failed')
|
||||
|
||||
useEffect(() => {
|
||||
if (!library?.slug) {
|
||||
previousLibraryStateRef.current = null
|
||||
clearToasts()
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
slug: library.slug,
|
||||
hasFiles: !!library.files?.length,
|
||||
isSyncing,
|
||||
isReadyForChat,
|
||||
hasFailedFiles
|
||||
}
|
||||
|
||||
const previousState = previousLibraryStateRef.current
|
||||
if (!previousState || previousState.slug !== nextState.slug) {
|
||||
previousLibraryStateRef.current = nextState
|
||||
clearToasts()
|
||||
return
|
||||
}
|
||||
|
||||
if (!previousState.isSyncing && nextState.isSyncing) {
|
||||
queueToast(
|
||||
'Syncing this database. Heimgeist is rebuilding the corpus and indexes automatically, and only selected files will run through enrichment.'
|
||||
)
|
||||
}
|
||||
|
||||
if (previousState.isSyncing && !nextState.isSyncing) {
|
||||
if (nextState.hasFailedFiles) {
|
||||
queueToast(
|
||||
'Some files did not finish syncing. Their tiles show the failure state and error details.',
|
||||
'warning'
|
||||
)
|
||||
} else if (nextState.isReadyForChat) {
|
||||
queueToast(
|
||||
'Sync complete. This database is ready in chat. Raw indexing stays on by default; enable enrichment only for files that need deeper recall.',
|
||||
'success'
|
||||
)
|
||||
} else if (!nextState.hasFiles) {
|
||||
queueToast('Add files to make this database available in chat.')
|
||||
}
|
||||
} else if (previousState.hasFiles && !nextState.hasFiles && !nextState.isSyncing) {
|
||||
queueToast('All files were removed. Add files to make this database available in chat.')
|
||||
} else if (!previousState.hasFailedFiles && nextState.hasFailedFiles && !nextState.isSyncing) {
|
||||
queueToast(
|
||||
'Some files did not finish syncing. Their tiles show the failure state and error details.',
|
||||
'warning'
|
||||
)
|
||||
}
|
||||
|
||||
previousLibraryStateRef.current = nextState
|
||||
}, [
|
||||
library?.slug,
|
||||
library?.files?.length,
|
||||
hasFailedFiles,
|
||||
isReadyForChat,
|
||||
isSyncing
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="library-panel">
|
||||
{confirmDelete && (
|
||||
<div className="library-inline-form danger-zone">
|
||||
<div className="muted-copy">Delete "{library.name}"? This removes the registered files and local retrieval data for this database.</div>
|
||||
<div className="new-db-actions">
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={busy}
|
||||
onClick={() => deleteLibrary().catch((error) => setErrorMessage(String(error?.message || error)))}
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
||||
<div className="library-panel-scroll">
|
||||
{confirmDelete && (
|
||||
<div className="library-inline-form danger-zone">
|
||||
<div className="muted-copy">Delete "{library.name}"? This removes the registered files and local retrieval data for this database.</div>
|
||||
<div className="new-db-actions">
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={busy}
|
||||
onClick={() => deleteLibrary().catch((error) => setErrorMessage(String(error?.message || error)))}
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => setConfirmDelete(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && <div className="form-error">{errorMessage}</div>}
|
||||
|
||||
<div className="library-toolbar">
|
||||
<button className="button" disabled={busy} onClick={addPaths}>Add Files</button>
|
||||
{library.files?.length > 0 && !isSyncing && !isReadyForChat && (
|
||||
<button className="button ghost" disabled={busy} onClick={retrySync}>Retry Sync</button>
|
||||
)}
|
||||
<button
|
||||
className="button danger"
|
||||
onClick={() => {
|
||||
setConfirmDelete(true)
|
||||
setErrorMessage('')
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="library-states">
|
||||
<div className={`state-pill ${library.states?.has_files ? 'ready' : ''}`}>Files: {library.files?.length || 0}</div>
|
||||
<div className={`state-pill ${(library.states?.enrichment_enabled_files || 0) > 0 ? 'ready' : ''}`}>
|
||||
Enrich: {library.states?.enrichment_enabled_files || 0}
|
||||
</div>
|
||||
<div className={`state-pill ${isReadyForChat ? 'ready' : ''}`}>
|
||||
{isSyncing ? 'Syncing' : isReadyForChat ? 'Ready' : library.files?.length ? 'Needs sync' : 'No data yet'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && <div className="form-error">{errorMessage}</div>}
|
||||
|
||||
<div className="library-toolbar">
|
||||
<button className="button" disabled={busy} onClick={addPaths}>Add Files</button>
|
||||
{library.files?.length > 0 && !isSyncing && !isReadyForChat && (
|
||||
<button className="button ghost" disabled={busy} onClick={retrySync}>Retry Sync</button>
|
||||
)}
|
||||
<button
|
||||
className="button danger"
|
||||
onClick={() => {
|
||||
setConfirmDelete(true)
|
||||
setErrorMessage('')
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="library-states">
|
||||
<div className={`state-pill ${library.states?.has_files ? 'ready' : ''}`}>Files: {library.files?.length || 0}</div>
|
||||
<div className={`state-pill ${(library.states?.enrichment_enabled_files || 0) > 0 ? 'ready' : ''}`}>
|
||||
Enrich: {library.states?.enrichment_enabled_files || 0}
|
||||
</div>
|
||||
<div className={`state-pill ${isReadyForChat ? 'ready' : ''}`}>
|
||||
{isSyncing ? 'Syncing' : isReadyForChat ? 'Ready' : library.files?.length ? 'Needs sync' : 'No data yet'}
|
||||
<div className="library-files">
|
||||
<h2>Files</h2>
|
||||
{library.files?.length ? (
|
||||
<div className="library-file-list">
|
||||
{library.files.map(file => {
|
||||
const sync = fileSyncMeta(file)
|
||||
return (
|
||||
<div key={file.sha256 || file.rel} className="library-file-row">
|
||||
<div className="library-file-meta">
|
||||
<div className="library-file-name">{file.name || file.path}</div>
|
||||
<div className="library-file-path">{file.path}</div>
|
||||
<div className={`library-file-mode ${file.enrich_enabled ? 'enabled' : ''}`}>
|
||||
{file.enrich_enabled ? 'Enrichment on' : 'Raw only'}
|
||||
</div>
|
||||
<div className="library-file-sync">
|
||||
<div className="library-file-sync-row">
|
||||
<span className={`library-file-sync-label ${sync.status}`}>{sync.label}</span>
|
||||
<span className="library-file-sync-detail">{sync.detail}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`library-file-progress ${sync.status}`}
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={Math.round(sync.progress)}
|
||||
aria-label={`${file.name || file.path} sync progress`}
|
||||
>
|
||||
<div
|
||||
className="library-file-progress-bar"
|
||||
style={{ width: `${sync.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="library-file-actions">
|
||||
<button
|
||||
className="button ghost"
|
||||
disabled={busy || isSyncing}
|
||||
onClick={() => updateFileEnrichment(file.rel, !file.enrich_enabled)}
|
||||
>
|
||||
{file.enrich_enabled ? 'Use Raw Only' : 'Enable Enrich'}
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => window.electronAPI?.openPath?.(file.path)}>Open</button>
|
||||
<button className="button ghost" disabled={busy || isSyncing} onClick={() => removeFile(file.rel)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-copy">No files registered yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSyncing && (
|
||||
<div className="library-chat-note">
|
||||
Syncing this database. Heimgeist is rebuilding the corpus and indexes automatically, and only selected files will run through enrichment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!library.files?.length && !isSyncing && (
|
||||
<div className="library-chat-note">
|
||||
Add files to make this database available in chat.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!library.files?.length && !isSyncing && (
|
||||
<div className="library-chat-note">
|
||||
Raw indexing is the default fast path. Turn on enrichment only for files that need better summaries, entities, and semantic recall.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFailedFiles && !isSyncing && (
|
||||
<div className="library-chat-note">
|
||||
Some files did not finish syncing. Their tiles show the failure state and error details.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeJobs.length > 0 && (
|
||||
<div className="library-jobs">
|
||||
{activeJobs.map(job => (
|
||||
<div key={job.id} className={`job-card ${job.status}`}>
|
||||
{statusLabel(job)}
|
||||
{toasts.length > 0 && (
|
||||
<div className="library-toast-stack" aria-live="polite">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`library-toast ${toast.tone}`}
|
||||
role={toast.tone === 'warning' ? 'alert' : 'status'}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="library-files">
|
||||
<h2>Files</h2>
|
||||
{library.files?.length ? (
|
||||
<div className="library-file-list">
|
||||
{library.files.map(file => {
|
||||
const sync = fileSyncMeta(file)
|
||||
return (
|
||||
<div key={file.sha256 || file.rel} className="library-file-row">
|
||||
<div className="library-file-meta">
|
||||
<div className="library-file-name">{file.name || file.path}</div>
|
||||
<div className="library-file-path">{file.path}</div>
|
||||
<div className={`library-file-mode ${file.enrich_enabled ? 'enabled' : ''}`}>
|
||||
{file.enrich_enabled ? 'Enrichment on' : 'Raw only'}
|
||||
</div>
|
||||
<div className="library-file-sync">
|
||||
<div className="library-file-sync-row">
|
||||
<span className={`library-file-sync-label ${sync.status}`}>{sync.label}</span>
|
||||
<span className="library-file-sync-detail">{sync.detail}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`library-file-progress ${sync.status}`}
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={Math.round(sync.progress)}
|
||||
aria-label={`${file.name || file.path} sync progress`}
|
||||
>
|
||||
<div
|
||||
className="library-file-progress-bar"
|
||||
style={{ width: `${sync.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="library-file-actions">
|
||||
<button
|
||||
className="button ghost"
|
||||
disabled={busy || isSyncing}
|
||||
onClick={() => updateFileEnrichment(file.rel, !file.enrich_enabled)}
|
||||
>
|
||||
{file.enrich_enabled ? 'Use Raw Only' : 'Enable Enrich'}
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => window.electronAPI?.openPath?.(file.path)}>Open</button>
|
||||
<button className="button ghost" disabled={busy || isSyncing} onClick={() => removeFile(file.rel)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-copy">No files registered yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user