diff --git a/pb_migrations/1780700000_sources_progress.js b/pb_migrations/1780700000_sources_progress.js
new file mode 100644
index 0000000..f971385
--- /dev/null
+++ b/pb_migrations/1780700000_sources_progress.js
@@ -0,0 +1,21 @@
+///
+migrate((app) => {
+ const sources = app.findCollectionByNameOrId("sources");
+
+ sources.fields.add(new Field({
+ "hidden": false,
+ "id": "json_progress",
+ "maxSize": 0,
+ "name": "progress",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ }));
+
+ app.save(sources);
+}, (app) => {
+ const sources = app.findCollectionByNameOrId("sources");
+ sources.fields.removeById("json_progress");
+ app.save(sources);
+});
diff --git a/src/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx
index 912b94c..b03bba4 100644
--- a/src/components/admin/CurriculumManager.jsx
+++ b/src/components/admin/CurriculumManager.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
+import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
@@ -26,6 +27,9 @@ const CurriculumManager = () => {
const [generationReason, setGenerationReason] = useState('');
+ // Prevent tab close during long AI operations
+ useBeforeUnload(isGenerating || isEnriching);
+
const currentIsoWeek = (() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx
index b17e447..d677cb9 100644
--- a/src/components/admin/UploadZone.jsx
+++ b/src/components/admin/UploadZone.jsx
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react';
-import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus } from 'lucide-react';
+import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus, AlertTriangle, Trash2 } from 'lucide-react';
import { processSourceText } from '../../lib/extractionPipeline';
+import { pb } from '../../lib/pb';
+import * as db from '../../lib/db';
+import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import Card from '../ui/Card';
import Button from '../ui/Button';
@@ -11,10 +14,31 @@ const UploadZone = ({ onUploadComplete }) => {
const [queue, setQueue] = useState([]);
const [processingId, setProcessingId] = useState(null);
const [rejectedNote, setRejectedNote] = useState(null);
+ const [orphanedSources, setOrphanedSources] = useState([]);
const fileInputRef = useRef(null);
const abortRef = useRef(null);
+ // ── Tab-close guard ─────────────────────────────────────────────────────────
+ useBeforeUnload(processingId !== null);
+
+ // ── Detect orphaned sources on mount ────────────────────────────────────────
+ useEffect(() => {
+ db.getOrphanedSources().then(setOrphanedSources);
+ }, []);
+
+ const handleResetOrphan = async (id) => {
+ await db.updateSourceStatus(id, 'failed', 'Interrupted — tab was closed during extraction');
+ setOrphanedSources((prev) => prev.filter((s) => s.id !== id));
+ if (onUploadComplete) onUploadComplete();
+ };
+
+ const handleDeleteOrphan = async (id) => {
+ await db.deleteSource(id);
+ setOrphanedSources((prev) => prev.filter((s) => s.id !== id));
+ if (onUploadComplete) onUploadComplete();
+ };
+
// ── Processing loop ────────────────────────────────────────────────────────
useEffect(() => {
@@ -28,10 +52,38 @@ const UploadZone = ({ onUploadComplete }) => {
const controller = new AbortController();
abortRef.current = controller;
+ // Poll for progress updates from PocketBase while processing
+ let progressInterval;
+ let lastSourceId = null;
+
next.file.text()
- .then((text) => processSourceText(text, next.name, { signal: controller.signal }))
+ .then(async (text) => {
+ // Start processing — get source ID from the pipeline to poll progress
+ const resultPromise = processSourceText(text, next.name, { signal: controller.signal });
+
+ // Find the newly created source record to track its progress
+ const sources = await db.getSources();
+ const sourceRec = sources.find(s => s.name === next.name && s.status === 'processing');
+ if (sourceRec) {
+ lastSourceId = sourceRec.id;
+ progressInterval = setInterval(async () => {
+ try {
+ const updated = await pb.collection('sources').getOne(lastSourceId);
+ if (updated.progress) {
+ setQueue((q) => q.map((item) =>
+ item.id === next.id
+ ? { ...item, progress: updated.progress }
+ : item
+ ));
+ }
+ } catch { /* source may have been deleted */ }
+ }, 2000);
+ }
+
+ return resultPromise;
+ })
.then(() => {
- setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done' } : item));
+ setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done', progress: null } : item));
if (onUploadComplete) onUploadComplete();
})
.catch((err) => {
@@ -45,11 +97,12 @@ const UploadZone = ({ onUploadComplete }) => {
setQueue((q) => q.map((item) =>
item.id === next.id
- ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg }
+ ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : errorMsg, progress: null }
: item
));
})
.finally(() => {
+ if (progressInterval) clearInterval(progressInterval);
abortRef.current = null;
setProcessingId(null);
});
@@ -62,7 +115,7 @@ const UploadZone = ({ onUploadComplete }) => {
let rejected = 0;
for (const file of fileList) {
if (file.type === 'text/plain' || file.name.endsWith('.md')) {
- accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null });
+ accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null, progress: null });
} else {
rejected++;
}
@@ -109,6 +162,52 @@ const UploadZone = ({ onUploadComplete }) => {
return (
+ {/* ─── Orphaned Sources Warning ─── */}
+ {orphanedSources.length > 0 && (
+
+
+
+
+
Interrupted extraction{orphanedSources.length > 1 ? 's' : ''} detected
+
+ {orphanedSources.length === 1
+ ? 'A source extraction was interrupted (tab closed or browser crashed). You can delete it and re-upload the file.'
+ : `${orphanedSources.length} source extractions were interrupted. Delete and re-upload the files to retry.`}
+
+
+
+
+ {orphanedSources.map((s) => (
+
+
+ {s.name}
+ {s.progress && (
+
+ (stopped at chunk {s.progress.current}/{s.progress.total})
+
+ )}
+
+
+ handleResetOrphan(s.id)}
+ className="text-xs text-amber-700 hover:text-amber-900 underline"
+ >
+ Mark failed
+
+ handleDeleteOrphan(s.id)}
+ className="p-1.5 text-red-500 hover:text-red-600 hover:bg-red-50 rounded-[var(--r-sm)] transition-colors"
+ title="Delete"
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
{/* ─── Drag & Drop Zone ─── */}
{
{item.name}
- {item.status === 'processing' && 'Extracting…'}
+ {item.status === 'processing' && (
+ item.progress
+ ? `Chunk ${item.progress.current + 1}/${item.progress.total}`
+ : 'Starting…'
+ )}
{item.status === 'pending' && 'Pending'}
{item.status === 'done' && 'Done'}
{item.status === 'cancelled' && 'Cancelled'}
diff --git a/src/hooks/useBeforeUnload.js b/src/hooks/useBeforeUnload.js
new file mode 100644
index 0000000..7061cef
--- /dev/null
+++ b/src/hooks/useBeforeUnload.js
@@ -0,0 +1,22 @@
+import { useEffect } from 'react';
+
+/**
+ * Prevent accidental tab/window closure while a long-running operation
+ * is in progress. Shows the browser's native "Leave site?" confirmation.
+ *
+ * @param {boolean} active — true while the operation is running
+ */
+export function useBeforeUnload(active) {
+ useEffect(() => {
+ if (!active) return;
+
+ const handler = (e) => {
+ e.preventDefault();
+ // Legacy browsers require returnValue to be set
+ e.returnValue = '';
+ };
+
+ window.addEventListener('beforeunload', handler);
+ return () => window.removeEventListener('beforeunload', handler);
+ }, [active]);
+}
diff --git a/src/lib/db.js b/src/lib/db.js
index b3dabb2..9c0d11b 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -133,6 +133,24 @@ export async function updateSourceStatus(id, status, error = '') {
return pb.collection('sources').update(id, { status, error });
}
+export async function updateSourceProgress(id, progress) {
+ return pb.collection('sources').update(id, { progress });
+}
+
+/**
+ * Find sources stuck in 'processing' status — likely orphaned by a tab close.
+ * Sources older than 5 minutes in 'processing' state are considered stale.
+ */
+export async function getOrphanedSources() {
+ try {
+ const all = await pb.collection('sources').getFullList({
+ filter: 'status="processing"',
+ });
+ const fiveMinAgo = Date.now() - 5 * 60 * 1000;
+ return all.filter(s => new Date(s.updated || s.created).getTime() < fiveMinAgo);
+ } catch { return []; }
+}
+
export async function deleteSource(id) {
return pb.collection('sources').delete(id);
}
diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js
index fcc0adc..f3e93b3 100644
--- a/src/lib/extractionPipeline.js
+++ b/src/lib/extractionPipeline.js
@@ -128,6 +128,9 @@ export async function processSourceText(textContent, sourceName, { signal } = {}
const chunks = chunkText(textContent);
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
+ // Persist initial progress so other sessions/reloads can see it
+ await db.updateSourceProgress(sourceId, { current: 0, total: chunks.length, message: 'Starting extraction...' });
+
const existingTopics = await db.getTopics();
const knownTopics = existingTopics.map((t) => ({ id: t.id, label: t.label }));
@@ -138,6 +141,14 @@ export async function processSourceText(textContent, sourceName, { signal } = {}
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
+
+ // Update progress before each chunk
+ await db.updateSourceProgress(sourceId, {
+ current: i,
+ total: chunks.length,
+ message: `Extracting chunk ${i + 1} of ${chunks.length}...`,
+ });
+
const hint = buildKnownIdsHint(knownTopics);
const result = await callLLM({
task: 'extract.source',
@@ -168,6 +179,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {}
}
}
+ await db.updateSourceProgress(sourceId, { current: chunks.length, total: chunks.length, message: 'Merging results...' });
await mergeKnowledgeGraph(existingTopics, { topics: allExtractedTopics, relations: allExtractedRelations });
await db.updateSourceStatus(sourceId, 'completed');