feat(sources): real-time file progress for sync and upload — background upload with success toast

This commit is contained in:
2026-09-01 23:51:43 -04:00
parent cddc84c7db
commit 4677d86f49
103 changed files with 5914 additions and 456 deletions
+159 -28
View File
@@ -14,6 +14,13 @@
* page "new chat" means going to the chat, fresh — the module clears
* the phase-14 conversation key and navigates to "/").
*
* Phase 64 (task 04): the sync button is also the live progress face
* of the BACKGROUND archive scan (task 03) — while an upload import
* is in flight the button animates with the upload's current file
* ("Importing <file>"), settles + refreshes the catalog when it
* finishes, and re-attaches to it on page load. See the sync-button
* block below for the full two-job contract.
*
* Phase 26: the table's path links open the document in the
* almost-fullscreen modal overlay (assets/document-modal.js) on the
* SAME page — no new tab, no navigation. The link keeps its
@@ -36,6 +43,33 @@ import { fetchIsAdmin, initSharedHeader } from "./header.js";
* opens an error modal (same as the former header.js module — recreated
* here since the navbar button is gone).
*
* Phase 64 (task 04): the button reports the FILE being processed, not
* just "Syncing…" — TWO jobs drive it. Each poll tick fetches BOTH
* status endpoints — GET /api/sync/status +
* GET /api/git-sources/upload/status — and applies this decision
* tree, in order (startSyncPolling):
* 1. sync running → "Syncing… <file> (n/m)" — bare "Syncing…" until
* the import's first file (clone/pull, A4);
* 2. upload running → "Importing <file> (n/m)" — the background
* archive scan (the "clicked upload, then opened
* sources" contract, A3);
* 3. sync success → the phase-32 settle (counts + catalog refresh);
* 4. sync failed → the phase-32 failure (banner + modal);
* 5. upload success → settle "Sync sources" + catalog refresh
* (loadDocs — the new documents must appear); the
* upload's counts live on the Sources page, never
* in #sync-result (A3);
* 6. upload failed → settle "Sync sources" — the failure is the
* Sources page's error banner, never this page's (A3);
* 7. both idle → retry-ready idle.
* The live label is the status endpoint's full source/relative/path
* (A4): CSS ellipsizes #sync-label; the full untruncated path also
* rides the button title (hover) and #sync-result (the aria-live
* announcer — screen readers hear it). The load-time re-attach
* (initSyncButton) re-enters a RUNNING upload the same way; a terminal
* upload is a no-op there (the boot-time loadDocs() already shows the
* current catalog).
*
* Elements: #sync-btn (the button), #sync-label (the text),
* #sync-icon (the spinner icon), #sync-result (aria-live result
* line), #sync-error-banner / #sync-error-text (error banner).
@@ -70,6 +104,22 @@ function fmtSyncTime(iso) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
* "sync" → "Syncing…", "upload" → "Importing" (the background scan's
* word, A3). The current file — the status endpoint's full
* source/relative/path (A4) — is appended while one is being processed;
* the BARE prefix shows during the clone/pull (sync) or unpack (upload)
* phase, before any file is indexed. The counts appear only once the
* import has started (total > 0). CSS ellipsizes the button label; the
* same untruncated text goes to the button title + #sync-result (the
* aria-live announcer). */
function fmtSyncLabel(kind, currentFile, done, total) {
const prefix = kind === "upload" ? "Importing" : "Syncing…";
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
if (total > 0) label += ` (${done}/${total})`;
return label;
}
function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
@@ -88,15 +138,25 @@ function sanitizeSyncError(message) {
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
function enterSyncRunningState() {
/* The single running-state entry point (phase 64 task 04: the label
* carries the live file — `kind` "sync" | "upload", the status
* endpoint's current_file + done/total). Same mechanics as before
* (disabled, aria-busy, spinning icon, no is-error) plus: the full
* untruncated path on the button title (removed when null — no file
* yet) and in #sync-result (the aria-live announcer reads the full
* live path; CSS ellipsizes the button's label span only). */
function enterSyncRunningState(kind, currentFile, done, total) {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
syncBtn.removeAttribute("title");
if (currentFile) syncBtn.title = currentFile;
else syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
if (syncLabel) syncLabel.textContent = "Syncing…";
const label = fmtSyncLabel(kind, currentFile, done, total);
if (syncLabel) syncLabel.textContent = label;
if (syncResult) syncResult.textContent = label;
}
function settleSyncButton(label) {
@@ -193,15 +253,22 @@ function applySyncIdle(status) {
emitSyncStatus(status || { state: "idle" });
}
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
* sync AND the background upload scan — and applies the two-job
* decision tree in order (see the section header). The 403 on the SYNC
* fetch hides the button (the whoami backstop); a 403 on the UPLOAD
* fetch is simply "no upload" (never a hide), and a network blip on
* either fetch retries next tick. */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let status = null;
let syncStatus = null;
let uploadStatus = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) status = await r.json();
else if (r.ok) syncStatus = await r.json();
} catch { /* network blip — retry next tick */ }
if (notAdmin) {
stopSyncPolling();
@@ -209,28 +276,66 @@ function startSyncPolling() {
applySyncIdle();
return;
}
if (!status) {
// The SECOND job: the background upload scan (admin-only surface).
try {
const ur = await fetch("/api/git-sources/upload/status");
if (ur.ok) uploadStatus = await ur.json();
} catch { /* network blip — retry next tick */ }
if (!syncStatus) {
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
// 1. sync running: the live sync file (bare "Syncing…" until the
// import's first file — A4).
if (syncStatus.state === "running") {
enterSyncRunningState(
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total
);
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
// 2. upload running: the same animation, the upload's file (A3).
if (uploadStatus && uploadStatus.state === "running") {
enterSyncRunningState(
"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total
);
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (status.state === "idle") {
if (syncStatus.state === "success") {
stopSyncPolling();
applySyncIdle(status);
applySyncSuccess(syncStatus);
return;
}
// Still running: keep button state honest and re-schedule.
enterSyncRunningState();
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
if (syncStatus.state === "failed") {
stopSyncPolling();
applySyncFailure(syncStatus);
return;
}
// 5. upload success: settle + catalog refresh (A3 — the upload's
// counts live on the Sources page; #sync-result stays empty).
if (uploadStatus && uploadStatus.state === "success") {
stopSyncPolling();
settleSyncButton("Sync sources");
if (syncResult) syncResult.textContent = "";
hideSyncError();
emitSyncStatus({ state: "idle" });
loadDocs();
return;
}
// 6. upload failed: settle only — the failure is the Sources page's
// error banner, never this page's (A3).
if (uploadStatus && uploadStatus.state === "failed") {
stopSyncPolling();
settleSyncButton("Sync sources");
if (syncResult) syncResult.textContent = "";
hideSyncError();
emitSyncStatus({ state: "idle" });
return;
}
// 7. both idle: settle retry-ready.
stopSyncPolling();
applySyncIdle(syncStatus);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
@@ -253,8 +358,10 @@ async function startSync() {
return;
}
if (r.status === 202 || r.status === 409) {
enterSyncRunningState();
if (syncResult) syncResult.textContent = "";
// Phase 64: the run is just starting (model check / clone-pull) —
// bare "Syncing…" until the first polled file (A4); entering the
// running state also clears #sync-result with the same label.
enterSyncRunningState("sync", null, 0, 0);
hideSyncError();
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
startSyncPolling();
@@ -268,8 +375,12 @@ async function startSync() {
});
}
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running state,
* a terminal run renders its last result. */
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
* state, a terminal run renders its last result. Phase 64 (A3): with
* the sync IDLE, an in-flight background upload scan adopts the button
* the same way — the "user clicked upload, then opened sources" case;
* a terminal upload is a no-op (the boot-time loadDocs() already shows
* the current catalog). */
async function initSyncButton() {
if (!syncBtn) return;
if (!(await fetchIsAdmin())) return;
@@ -281,16 +392,36 @@ async function initSyncButton() {
status = await r.json();
} catch { return; }
if (status.state === "running") {
enterSyncRunningState();
enterSyncRunningState(
"sync", status.current_file, status.files_done, status.files_total
);
emitSyncStatus(status);
startSyncPolling();
} else if (status.state === "success") {
applySyncSuccess(status);
} else if (status.state === "failed") {
applySyncFailure(status);
} else {
applySyncIdle(status);
return;
}
if (status.state === "success") {
applySyncSuccess(status);
return;
}
if (status.state === "failed") {
applySyncFailure(status);
return;
}
// Sync idle: check the SECOND job — an in-flight upload scan re-attaches.
let upload;
try {
const ur = await fetch("/api/git-sources/upload/status");
if (ur.ok) upload = await ur.json();
} catch { /* network blip — the idle settle below is still honest */ }
if (upload && upload.state === "running") {
enterSyncRunningState(
"upload", upload.current_file, upload.files_done, upload.files_total
);
emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
applySyncIdle(status);
}
if (syncBtn) {