Files
brain-of-reese/frontend/assets/sources.js
T
ducoterra edf6048a0d
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 150_drop_sync_result
**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass.

- Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP.
- Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests).
- Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`).
- Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed.
- Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched.
- Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
2026-10-02 02:35:03 -04:00

2117 lines
102 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Brain of Reese — RAG view (the knowledge base catalog; phase 76
* task 02: shell view module — formerly the standalone sources.html).
*
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
* full-width document table, or the designed empty state when nothing
* is indexed yet. Cells are built with DOM APIs (textContent) — never
* innerHTML with document-derived data (XSS-safe by construction).
*
* Phase 76 (task 02) — shell view module (the "RAG" view of the
* ONE-document shell; /sources.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-rag">, and every DOM lookup
* scopes to root (the view ids stay unique across the shell —
* scoped lookups keep the module honest and testable). The router
* mounts a view ONCE (mount-once, hide-forever), so the bindings,
* the sync state machine, and the in-flight poll survive every
* switch.
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The admin gate (sync button
* reveal + the anonymous catalog gate) keeps fetchIsAdmin() — the
* SAME cached /api/whoami promise header.js exports (zero extra
* requests).
* • the document modal needs NO wiring change: the shell keeps
* EXACTLY ONE #doc-modal-* skeleton (the chat's, body level), and
* both app.js (chat chips) and this module (RAG row links) open
* documents through the shared openDocumentModal(...) against that
* single instance (assets/document-modal.js, resolved by
* document-level querySelector at import).
*
* The sync button (phase 32) + the live two-job progress contract
* (phase 64 task 04) and the table rows (phase 26: same-page modal
* links) are unchanged in content — only the boot shape moved.
*
* Phase 77 (task 02) — the re-show refresh: the shell router
* dispatches `bor:view-refresh` on the view's section when the user
* RE-SHOWS an already-mounted view (a switch back onto it, a re-click
* of the RAG nav link, or back/forward) — the first show (mount) and
* boot never (the mount's own load is the first fetch). This module
* listens on root and re-runs the catalog load, which is re-entrant:
* a re-load clears the row containers' rows BEFORE filling them (the
* History pattern from task 01 — phase 97 moved the clear from the
* fetch top into the render, and from the one tbody to both), so a
* refresh from a populated level into a sparser one replaces the rows
* (no ghost rows). #sources-empty lives OUTSIDE the tbody (a
* .empty-state div, not a row), so the clear is a bare
* replaceChildren(). The listener is armed only in the ADMIN
* branch, after the whoami gate passes: anonymous shows the gate and
* never fetches the catalog (the phase-16 soft rule).
*
* Phase 97 (task 04) — the catalog becomes the DRILL-DOWN TREE the
* agent's `ls` sees (the phase-94 concept, one end to end). The load
* is now `loadTree()`: ONE fetch of GET /api/docs/tree returns the
* FULL recursive tree (sources → folders → files, each folder carrying
* its STORED description — the rows the agent reads), and the view
* drills CLIENT-SIDE from that single fetch — zero per-level fetches,
* no URL change (the navigation is a re-render of the already-fetched
* tree).
*
* • the drill state is module-scoped: `current` —
* { source: null, folder: null } = the top level (the rows ARE the
* sources themselves — the ls() equivalence: name, recursive count,
* the (source, "") description), `folder: ""` = the source root,
* `folder: "one/two"` = the nested folder. The last fetched tree
* lives in the module-scoped `kbTree`.
* • `renderLevel()` from `current` + `kbTree`: #kb-crumb (hidden at
* the top — one link per ancestor: the top level, the source, then
* the folder chain, the last segment an aria-current span), #kb-level
* (the current level's STORED description — title = the full
* source-relative path; HIDDEN when none is stored — the ls rule:
* count only, no placeholder), #folders-table = the level's direct
* subfolders (top level: the sources), #docs-table = the level's
* DIRECT files only — makeRow UNCHANGED (the path link still opens
* the same-page modal; the no-JS href escape hatch intact), hidden
* when the level has none (at the top level it is ALWAYS hidden —
* files are seen per source, as with ls(source)).
* • the stat cards walk the WHOLE tree (document count, chunks sum,
* max indexed_at) — the values identical to the former flat walk.
* • the empty state (#sources-empty) is now the "zero SOURCES"
* semantic (nothing registered, nothing indexed) — the deliberate
* phase-97 change: a registered 0-document source renders its row
* (`0` documents) instead (the ls invariant — the agent lists it
* too). A failed tree fetch renders that same no-data state (the
* former flat-load failure behavior, unchanged in kind).
* • Phase 77/79 carry over: the monotonic loadSeq race token (only
* the newest load may touch the DOM after its await) and the
* re-entrant render — renderLevel() clears BOTH row containers
* BEFORE filling them, so a refresh from a populated level into a
* sparser one leaves no ghost rows. NEVER-STALE (PLAN §7.4): after
* a re-fetch (re-show, sync success, upload success), if the current
* location no longer exists in the NEW tree (source unregistered/
* pruned, folder vanished), `current` RESETS to the top level BEFORE
* rendering — no stale breadcrumb, no stale block.
* • the refresh wirings move with the rename: `loadTree()` at the
* boot load, the bor:view-refresh listener, applySyncSuccess, and
* the upload-success branch of startSyncPolling — the anonymous
* branch still NEVER fetches (no /api/docs/tree request at all).
* • the sync button / poll / label / banner / error-modal machinery
* and the document-modal wiring are UNTOUCHED (the sync section
* above is byte-identical save its two catalog-refresh call
* sites, which now call loadTree() — the phase-97 rename).
*
* Phase 97 (task 05) — the folder-description EDITOR (the phase-57
* affordance, mirrored). The owner edits (or clears) any directory's
* stored description with the EXACT file-summary interaction: Edit →
* inline textarea (prefilled via .value) → Save / Cancel → a
* role=status live-region status.
*
* • ONE shared function, wireDescriptionEdit(), drives BOTH
* surfaces: the static #kb-level Edit button (the current level's
* description — the block ships the <h2> + a .kb-level-body holding
* the <p> + the Edit button) and the ALWAYS-present Edit button in
* every source/folder row's Description cell (makeDescCell builds
* it inside makeSourceRow / makeFolderRow — a description can be
* CREATED where none is stored: a < 2-document folder, the
* generator's fail-soft miss — the editor opens prefilled with the
* empty string). NO whoami gate in the view: the RAG view is
* admin-only already (the phase-16 gate) and the endpoint's
* require_admin is the API-level gate.
* • Save → PATCH /api/folders/summary with { source, folder_path,
* summary } — folder_path "" for the source root, the
* source-relative folder path otherwise (both known from the
* target: the row's node, or `current` for the level). 200 →
* re-render the description text (textContent ONLY — the XSS
* contract) in the surface where the edit happened + status
* "Description updated."; an empty save (the server echoes
* summary null) → the text goes away (level block hidden / row
* cell emptied) + "Description cleared."; the in-memory kbTree
* node's summary is updated IN PLACE (no re-fetch — the tree state
* stays coherent; the re-fetch is the safety net) — and, since
* Phase 98 (task 04), its summary_pending flag is cleared IN
* PLACE on the same success path (a created/updated description
* is no longer pending — the D4 marker clears where the edit
* happened, no re-fetch). Failure
* (non-2xx / network) → neutral retry copy (the phase-55
* convention), the editor stays open with the user's text, the
* stored text untouched. Cancel → restore the text node.
* • the level block is PERSISTENT (reused across levels as you
* drill), so wireDescriptionEdit takes a getTarget() getter (a
* row's is a constant) and returns a handle whose reset() tears
* down an open editor before every re-render (PLAN §7.4 — a
* navigate-away or refresh never leaves a stale open editor).
* • every editor part is static createElement; the description text
* is a text node (textContent / .value) — this module never builds
* HTML from document-derived data (the house rule, unchanged).
*
* Phase 98 (task 02) — the sync button's phase-aware labels: the
* post-import span of a sync (the KB overview + the long
* folder-summary span) no longer reads as a stuck file count. The
* SYNC job's running label is now phase-aware (fmtSyncPhaseLabel —
* the section header below carries the decision tree): "overview" →
* "Writing KB overview…", "summaries" → "Summarizing folders…
* <source/folder> (n/m)" (the folder part omitted while
* current_summary is null — the phase's first poll), anything else
* (the null prelude, "import") → today's "Syncing… <file> (n/m)"
* byte-identical. The UPLOAD job (its status has no phase) keeps the
* bare label. The untruncated label rides the button title (now set
* for EVERY running label, not just file labels) + #sync-progress as
* before (A4).
*
* Phase 98 (task 04) — the "Summary pending" markers: the catalog
* tells the owner a summary is WAITING instead of showing an empty
* cell that reads as "missed". A source/folder node is
* summary_pending when its summary is due (recursive count >= 2 —
* the D3 rule, exactly the next sync's gap-fill set) but no stored
* description exists yet. The marker has TWO surfaces, both driven
* by the same node flag:
*
* • the row's Description cell (makeDescCell) — when the stored
* summary is empty AND node.summary_pending, the text span
* carries the class kb-summary-pending (the muted ink-soft pair,
* AA on --surface — text + color, never color alone; the row
* cell's font-size/line-height apply — no row-height change) +
* the text "Summary pending" + the D4 title, verbatim:
* "No stored description yet — the next sync will generate one."
* A stored summary
* shows the stored text (NEVER the marker); neither stored nor
* pending shows the empty cell (the ls rule, unchanged). The
* Edit button is UNCHANGED — always present (a manual save
* creates the row).
* • the level block (renderLevel) — #kb-level shows when the
* current level has a stored description (as today) OR is
* pending: title as today (the full source-relative path), and
* #kb-level-summary shows the stored text, or — when pending —
* the D4 pending note, verbatim:
* "No description stored yet — the next sync will generate one. (You can write one yourself.)"
* Neither stored nor pending stays hidden (the ls rule,
* unchanged). The block's
* Edit button (write one manually now) already ships in the
* static markup (phase 97, task 05).
* • the in-place clear: wireDescriptionEdit's success path sets
* node.summary_pending = false right after
* node.summary = data.summary — a created/updated description is
* no longer pending; the marker clears IN PLACE in the surface
* where the edit happened, no re-fetch (the re-fetch stays the
* safety net). closeEditor re-renders the display state from the
* node with the SAME three-state rule the surfaces use: the
* pending marker's muted class + tooltip CANNOT survive a save
* (a stale "next sync" tooltip under a just-created description),
* and a cancel restores the surface's pending display (each
* surface passes its pending copy via pendingText — the row's
* "Summary pending" marker, the level's D4 note — and its
* tooltip via pendingTitle, the row only).
*
* Phase 99 (task 01, D1) — the ONE-LINE Description clamp: the
* owner's long stored descriptions no longer stretch the catalog
* rows. A row's Description cell is now ONE flex wrapper
* (`.kb-desc-cell` — inside the <td>, which gains no new class) holding
* the text span (`.kb-desc-text` — flex: 1 1 auto + min-width: 0 (the
* flex item may shrink), overflow: hidden + text-overflow: ellipsis +
* white-space: nowrap: the first line that fits, ellipsized — the row
* height is ONE line, independent of the description's length) and the
* ALWAYS-present Edit button (`.kb-summary-edit`, flex: 0 0 auto — it
* never wraps to a second line; its spacing comes from the wrapper's
* gap, its old margin-left is gone). The clamp is VISUAL only: the
* full text stays in the DOM (the accessible name is unchanged) and
* rides the span's title (the hover escape hatch, the house pattern) —
* set for real description text whenever non-empty; the phase-98
* marker keeps its OWN D4 title (never overridden), the empty cell
* carries none. The editor's open/close swaps happen INSIDE the
* wrapper (wireDescriptionEdit's row container IS the wrapper — the
* <td> always keeps it; the level block's container stays
* .kb-level-body), and closeEditor toggles ONLY the
* kb-summary-pending class (the base classes survive) while re-deriving
* the clamped span's title from the stored text (a stale pre-edit
* title cannot survive a save). The level block (`.kb-level p`) keeps
* the FULL unclamped description — the escape hatch at the top when
* you click in.
*
* Phase 99 (task 02, D2) — the browser back/forward walks the
* breadcrumb: the drill state IS the history state. Every user-
* initiated drill (source-row click, folder-link click, breadcrumb-
* segment click — all through goTo) goes through applyTarget(target,
* true): when the target DIFFERS from the current level, it FIRST
* pushes a STATE-ONLY entry — history.pushState({ view: "rag", kb:
* target }, "") — then sets current + re-renders. The push is the
* STATE only: the second arg is "" and there is no third — the URL
* STAYS PUT (no new route, the phase-76 deep-link surface is
* untouched; the shell's pathname is the only route contract). A
* re-click of the current level pushes NOTHING (no duplicate history
* entries). Back/forward: the window popstate (armed ONCE at mount,
* admin branch only — the anonymous gate installs no listeners) ADOPTS
* the popped entry's state.kb via the no-push variant (applyTarget(kb,
* false) — the browser owns its own entries, so adopt/reset never
* push); an entry WITHOUT a kb (the boot entry, the router's view
* entries, any foreign state) RESETS the drill to the TOP level
* (applyTarget({ source: null, folder: null }, false)). Back at the
* top level pops the router's view entry — the view leaves, exactly
* as today (the router's pathname-based switch, UNCHANGED). The
* re-show (bor:view-refresh) aligns the same way BEFORE the re-fetch:
* a kb-carrying top entry (the active-link re-click pushed NOTHING —
* the drilled entry is still on top) KEEPS the drill; a kb-less entry
* (a fresh nav visit) starts at the top level. Boot pushes NOTHING
* (the browser's own entry is the base — the phase-76 boot contract),
* and the never-stale resetVanishedLocation is UNCHANGED (it resets
* current WITHOUT pushing when a re-fetch finds the level gone — it
* follows whatever the history state says; no new entry). The router
* module is UNTOUCHED: its popstate reads the pathname only, and a
* state-only push never changes it (so rag-internal entries never
* trigger a view switch; for a foreign entry the router has already
* switched views, and this listener's render of the now-hidden view
* is harmless).
*
* Phase 106 (task 08, D8) — the date COLUMNS (display only — the
* admin date EDITOR is task 09):
*
* • the file table gains the Created column BEFORE Indexed (the
* owner's verbatim position, D8): makeRow builds the date cells
* explicitly (the plain-td loop can't carry per-cell titles) —
* textContent = the locale date (fmtDate, the Indexed idiom), and
* the Created cell's title = the FULL ISO value (hover precision —
* the path-cell idiom; the E2E asserts on the locale-stable
* title, not on the toLocaleString output). The row object fed
* from the tree's file nodes carries created_at (task 05's tree
* shape — the flat GET /api/docs shape has the field too).
* • the folder/source table gains the Updated column BETWEEN
* Documents and Description (the owner's verbatim position): the
* subtree's MAX document created_at — the tree API DERIVES it
* (D9, never stored); makeSourceRow / makeFolderRow render
* fmtDate(updated_at) with the ISO value on the cell's title and
* the "–" null idiom (the statLast shape) for a 0-document
* source.
* • the stat cards are UNTOUCHED — they keep their indexed_at
* "last indexed" semantics (the owner asked for the column, not
* the cards).
*
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
* tree carries the image affordance (is_image / image_url / summary —
* OMITTED on text nodes, the wire-additive rule) when it is an image
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
* box (object-fit: cover, loading="lazy", alt = the summary — the
* vision description; a NULL summary falls back to the title) BEFORE
* the path link (the .kb-doc-path flex wrapper — the link keeps its
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
* first paint) swaps in the document glyph INSIDE the same fixed box
* (no layout shift beyond the box, no broken-image placeholder). Text
* rows never get a box (the pre-phase bare-link cell, byte-identical).
* The glyph is static SVG (aria-hidden — the alt text is the
* accessible content); the box + img are properties only, never
* innerHTML with document-derived data (the house rule).
*
* Phase 128 (task 01) — the sync label becomes PARTS: the running
* label's string is split into a shrinks-to-fit MAIN part (prefix +
* the file/folder path — the part CSS may ellipsize) and a pinned
* COUNT part ("(n/m)" — the position, NEVER clipped), so on a phone
* the file path ellipsizes to fit while the count stays on-screen
* (the phase-64 bug: with max-width lifted, the inline-block sized to
* its full content and the count ran off-screen). #sync-label now
* ships TWO child spans — .sync-label-main (the initial idle text
* lives there so the pre-JS label renders) + .sync-label-count
* (hidden + empty by default) — and the label builders no longer
* return one string: fmtSyncLabel / fmtSyncPhaseLabel return the
* PARTS ({ main, count } — the same decision tree, the same byte-
* identical copy), and ONE writer — setSyncLabel(mainText,
* countText) — lands them: mainText → the main part's textContent,
* countText → the count part's textContent with the hidden toggle
* (absent count → hidden + empty), then the UNTRUNCATED full label
* — mainText + (countText ? " " + countText : "") — onto the button
* title + #sync-progress (A4 — the screen reader hears the whole
* thing; the visible span carries main + count, the space between
* them is the CSS gap). Every other #sync-label writer — the idle /
* settle labels ("Sync sources", "Synced HH:MM"), the 202/409 click,
* the initSyncButton re-attach — goes through setSyncLabel(main)
* (count hidden). The full-string shapes are byte-identical to the
* pre-task labels ("Syncing…", "Syncing… <file>",
* "Syncing… <file> (n/m)", "Importing", "Writing KB overview…",
* "Summarizing folders… <folder> (n/m)"); textContent only — no HTML
* strings (the house rule).
*
* Phase 134 (task 02) — the Cancel control + the cancelled terminal:
* the owner can stop a running sync. #sync-cancel-btn (the static
* markup next to #sync-btn — ship-hidden, the same convention) is
* visible + enabled ONLY while a SYNC run is in flight:
* enterSyncRunningState shows it for kind "sync" (the live click, the
* load-time re-attach, and every running poll tick) and hides it for
* kind "upload" (the upload job is NOT cancellable in this phase —
* its run keeps the control hidden); settleSyncButton hides it on
* EVERY settle (every terminal + both idles — one choke point). A
* click runs requestCancel() under the same never-stale contract as
* the main button: the control disables IMMEDIATELY (one request at
* a time — no double fire) BEFORE the POST /api/sync/cancel —
* 403 → the whoami-backstop hide-everything branch (the same depth as
* every other 403 in this module), 409 → SILENT (the run ended
* between the tick and the click — the poll settles on the next tick,
* which hides the control; no error copy), 202 → the control stays
* disabled until the terminal (the poll's new cancelled branch settles
* everything — the status object, not the request, is authoritative),
* any other non-2xx / network failure → the phase-55 neutral retry
* copy ("Couldn't cancel the sync — try again.") + re-enable (the
* run is still going — the owner can retry). The poll tree gains its
* 5th branch (the 7-branch tree becomes 8): state "cancelled" — after
* the failed branch, before the upload branches — stops the poll and
* runs applySyncCancelled: the NEUTRAL result (A1 — cancel is intent,
* not failure): settle the main button retry-ready (which also hides
* the Cancel control), "Sync cancelled" on the aria-live result line,
* the error banner hidden, the status frame emitted — no modal, no
* error styling. The initSyncButton re-attach renders the same result
* for a reload during/right after a cancel (a page reload shows
* "Sync cancelled", never a stuck running state). The bor:sync-status
* event shape is unchanged — the cancelled status object flows through
* emitSyncStatus exactly like every other state.
*
* Phase 149 (task 01) — the sync detail box + the icon-only running
* button: the owner's report was that the full live label (a long
* source/relative/path + the counts + the chunk part) is clipped in
* the pill even on desktop — the mobile solution (stuffing the parts
* in the button) had become the permanent surface. The fix: while a
* sync/upload run is in flight, the button shows ONLY the spinning
* icon (the `is-running` class — task 02's CSS hides the label parts;
* the button IS the loading spinner, exactly the owner's ask), and a
* NEW bordered text box under the button row (#sync-progress) shows
* the SAME full untruncated live label — wrapping, never clipped,
* breathing room. Phase 150 (task 02) folded the announcer into the
* box — the previous live-region span was retired and the box is now
* the SOLE sync text surface and the sole aria-live region (one live
* region, no double announce). The wiring rides the SINGLE writer:
* setSyncLabel gains the `running` flag (true on the running entry —
* the poll ticks, the 202/409 click, both re-attach entries; the
* settle default false) and writes the full label into the box +
* reveals/hides it, and owns the button's accessible name (full live
* label while running, the ship value "Sync sources" restored on
* every settle). The `is-running`
* class travels with the spin: added in enterSyncRunningState, removed
* in settleSyncButton (every terminal + both idles pass through it).
* The settle rendering ("Synced HH:MM" + the counts line), the Cancel
* control, the 409 double-trigger, the error banner/modal, and the
* sync status API are all UNCHANGED.
*
* Phase 150 (task 02) — one box, one writer: the phase-149
* live-region span that sat beside the box is retired —
* #sync-progress is the SOLE sync text surface AND the sole
* aria-live region (role="status" aria-live="polite", task 01).
* setSyncProgress(text) is the ONLY function that writes the box:
* the text lands on the inner #sync-progress-text span (textContent
* only — the house rule, never HTML) and the box's visibility is
* owned by this writer alone (hidden IFF the text is empty — the
* global [hidden] rule does the display). Two content modes, decided
* at the callsites (the phase-149 "the callsites are the only
* decision sites" rule):
* • running mode — setSyncLabel(main, count, chunk, running) writes
* the SAME untruncated full label through setSyncProgress, ONLY
* while running (the idle / settle button labels — "Sync sources",
* "Synced HH:MM" — must not fill the panel; they stay on the
* button, A2);
* • result mode — the settle paths write the result line through
* setSyncProgress: the counts line (fmtSyncResult — "N added ·
* …"), "Sync cancelled", the cancel-failure copy ("Couldn't
* cancel the sync — try again."); "" hides the box (the true
* idle, the two upload settles — the upload's result line lives
* on the Sources page, A3 — and the sync failure, whose banner +
* modal carry the error).
* requestCancel's pre-fetch clear is a NO-OP on the box (the run is
* still in flight — blanking would hide the running surface for up
* to one 2 s poll; the poll's `cancelled` branch settles it, and the
* control's immediate disable keeps the never-stale contract). The
* running label, the button title, and the button aria-label are
* byte-identical to pre-phase; no copy changed.
*/
import { fetchIsAdmin } from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
* query values are percent-encoded (paths contain slashes, sometimes
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
* context-menu escape hatch) — the left-click opens the same-page modal
* instead. */
export function documentUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
/* ---------- folder-description editing (phase 97, task 05) ----------
* The phase-57 edit affordance, mirrored for the RAG view's folder
* descriptions (the owner edits/clears any directory's stored
* description exactly like a file summary): Edit → inline textarea
* (prefilled via .value — the XSS contract) → Save / Cancel → a
* role=status live-region status, wired to PATCH /api/folders/summary.
* Every part is static createElement; the description text is a text
* node (textContent / .value) — this module never builds HTML from
* document-derived data (the house rule). */
function mkBtn(cls, label) {
const b = document.createElement("button");
b.type = "button";
b.className = cls;
b.textContent = label;
return b;
}
/* The shared description editor. `container` holds ONLY the
* description UI (the row cell's .kb-desc-cell wrapper — Phase 99
* (task 01) — or the level block's .kb-level-body)
* — the swap is a bare replaceChildren on it, so any persistent
* sibling (the level's <h2>) is untouched. `getTarget()` returns the
* current { node, source, folder } at open/save time: a row's is a
* constant (the row's node), the level's reads `current` +
* currentLevelNode() (the block is reused across levels). `node` is
* the in-memory kbTree node: its .summary is read on open (prefill)
* and updated IN PLACE on success (no re-fetch — the tree state stays
* coherent; the re-fetch is the safety net). `folder` is "" for the
* source root, the source-relative folder path otherwise. `onCleared`
* (optional) runs after a successful clear — the level block hides
* itself (the ls rule); a row cell just goes empty (the always-present
* button stays). Returns a handle whose reset() tears down an OPEN
* editor before a re-render (PLAN §7.4 — never a stale open editor). */
function wireDescriptionEdit({
editBtn,
textEl,
container,
getTarget,
onCleared,
pendingText,
pendingTitle,
}) {
const actions = document.createElement("div");
actions.className = "kb-summary-actions";
const saveBtn = mkBtn("kb-summary-save", "Save");
const cancelBtn = mkBtn("kb-summary-cancel", "Cancel");
actions.append(saveBtn, cancelBtn);
const status = document.createElement("p");
status.className = "kb-summary-status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
let editor = null;
let isOpen = false;
/* Back to the display state: the display state re-rendered from the
* node with the SAME three-state rule the surfaces use (makeDescCell
* / renderLevel — textContent/class/title only, the house rule):
* a stored summary → the stored text with NO marker (the in-place
* clear — the success path cleared node.summary_pending, so the
* muted pending style + the stale "next sync" tooltip cannot survive
* a saved description); no stored text but PENDING → the surface's
* pending display (pendingText — the row's "Summary pending" marker
* + its D4 tooltip, the level's D4 note — a cancel restores exactly
* the pre-edit state, including the marker); neither → the empty
* text. The Edit button is available again. A cleared node empties
* the text and, for the level block, hides the whole block via
* onCleared.
* Phase 99 (task 01): the class line toggles ONLY kb-summary-pending
* (the base classes survive — the row's span keeps .kb-desc-text, the
* clamp; the level's <p> keeps none), and the CLAMPED row span's
* title is re-derived from the stored text (the D1 hover escape hatch
* — a stale pre-edit title cannot survive a save; the level's <p> is
* unclamped — it shows the full text and keeps its no-title
* behavior). */
function closeEditor(message) {
const target = getTarget();
const node = target ? target.node : null;
const stored = node && typeof node.summary === "string" ? node.summary : "";
const pending = node !== null && stored === "" && node.summary_pending;
const value = pending && pendingText ? pendingText : stored;
textEl.classList.toggle("kb-summary-pending", pending);
if (pending) {
if (pendingTitle) textEl.title = pendingTitle;
} else if (stored && textEl.classList.contains("kb-desc-text")) {
textEl.title = stored; // D1: full text on hover — the clamp is visual only
} else {
textEl.removeAttribute("title");
}
textEl.textContent = value; // text node — the CURRENT display state
editBtn.hidden = false;
status.textContent = message;
container.replaceChildren(textEl, editBtn, status);
isOpen = false;
if (value.trim() === "" && onCleared) onCleared(); // level: hide the block
else editBtn.focus(); // return focus to the opener
}
function openEditor() {
if (isOpen) return;
const target = getTarget();
if (!target) return;
editor = document.createElement("textarea");
editor.className = "kb-summary-editor";
editor.value = typeof target.node.summary === "string" ? target.node.summary : ""; // .value, never innerHTML
status.textContent = "";
editBtn.hidden = true;
container.replaceChildren(editor, actions, status);
editor.focus();
isOpen = true;
}
async function saveDescription() {
const target = getTarget();
if (!target) return;
const { node, source, folder } = target;
const value = editor.value;
saveBtn.disabled = true; // one PATCH at a time (never stale)
status.textContent = "";
try {
const res = await fetch("/api/folders/summary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, folder_path: folder, summary: value }),
});
if (!res.ok) {
// Neutral retry copy (phase-55) — the editor stays OPEN with the
// user's text (no swap back, the stored text is untouched).
status.textContent = "Couldn't save the description — try again.";
return;
}
const data = await res.json();
node.summary = data.summary; // in-place kbTree update (no re-fetch)
node.summary_pending = false; // phase 98 (D4): a created/updated description is no longer pending — the marker clears in place (no re-fetch)
closeEditor(data.summary === null ? "Description cleared." : "Description updated.");
} catch {
// Network failure: the reachable? copy; the editor stays open.
status.textContent = "Couldn't save the description — is the app reachable?";
} finally {
saveBtn.disabled = false;
}
}
editBtn.addEventListener("click", openEditor);
saveBtn.addEventListener("click", () => {
void saveDescription();
});
cancelBtn.addEventListener("click", () => closeEditor(""));
return {
/* Tear down an OPEN editor with NO message and WITHOUT re-rendering
* the text from the (possibly stale) node — the surface is about to
* re-render for a DIFFERENT level (navigation / refresh): a stale
* open editor must never survive a re-render (PLAN §7.4). A closed
* editor is a no-op. */
reset() {
if (!isOpen) return;
editor = null;
saveBtn.disabled = false;
status.textContent = "";
editBtn.hidden = false;
container.replaceChildren(textEl, editBtn);
isOpen = false;
},
};
}
/* A source/folder row's Description cell (task 05): the stored
* description text (a text node, textContent only) + the ALWAYS-present
* Edit button (`.kb-summary-edit`) — a description can be CREATED where
* none is stored (a < 2-document folder, the generator's fail-soft
* miss), so the button is added unconditionally (the view is
* admin-only already — the endpoint's require_admin is the API gate).
* The shared editor is wired with a CONSTANT target (the row's node).
* `label` is the human name for the button's aria-label.
* Phase 98 (task 04, D4): the cell has THREE states — a stored summary
* (the stored text, textContent only), PENDING (no stored summary but
* node.summary_pending — the marker: the kb-summary-pending class +
* the "Summary pending" text + the D4 title; text + color, never color
* alone), and the empty cell (neither — the ls rule, unchanged). The
* Edit button is UNCHANGED in all three (a manual save creates the row
* and clears the marker in place — the editor's success path).
* Phase 99 (task 01, D1): the cell is ONE flex row — the <td> holds a
* single `div.kb-desc-cell` wrapper with the text span (`.kb-desc-text`
* — flexes + ellipsizes, so the row height is ONE line, independent of
* the description's length) and the Edit button (`.kb-summary-edit` —
* flex: 0 0 auto, it never wraps). The clamp is VISUAL only: the full
* text stays in the DOM and rides the span's title (the hover escape
* hatch, set for real description text whenever non-empty — the marker
* keeps its OWN D4 title, the empty cell carries none). The editor's
* open/close swaps happen INSIDE the wrapper (the container is the
* wrapper — the <td> always keeps it). */
function makeDescCell(node, source, folder, label) {
const td = document.createElement("td");
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
* ellipsizes, the button stays fixed (see the docstring above). */
const wrap = document.createElement("div");
wrap.className = "kb-desc-cell";
const text = document.createElement("span");
text.className = "kb-desc-text";
if (node && node.summary) {
text.textContent = node.summary; // stored description — text node, never innerHTML
text.title = node.summary; // full text on hover — the clamp is visual only (D1)
} else if (node && node.summary_pending) {
// Phase 98 (D4): the summary is due but not stored yet — the marker
// keeps its OWN title (the hover rule never overrides it).
text.classList.add("kb-summary-pending");
text.textContent = "Summary pending";
text.title = "No stored description yet — the next sync will generate one.";
} else {
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule, no title)
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = "kb-summary-edit";
btn.textContent = "Edit";
btn.setAttribute("aria-label", `Edit description: ${label}`);
wrap.append(text, btn);
td.append(wrap);
wireDescriptionEdit({
editBtn: btn,
textEl: text,
container: wrap, // Phase 99 (task 01): the swaps fill the WRAPPER (the <td> keeps it)
getTarget: () => ({ node, source, folder }),
// Phase 98 (D4): the row's pending display (closeEditor restores
// it on a cancel; a save clears the marker in place).
pendingText: "Summary pending",
pendingTitle: "No stored description yet — the next sync will generate one.",
});
return td;
}
export async function mount(root) {
/* ---------- Sync sources button (Sources page only) ----------
*
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
* → running (2 s poll of GET /api/sync/status) → success | failed.
* Admin-only: the button SHIPS hidden and the view boot below reveals
* it for the admin on the SAME cached whoami fetchIsAdmin() reads
* (no extra fetch); the 403 branches stay as defense in depth. A failed run
* 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 → the phase-aware label (Phase 98 task 02, D2)
* — the null-phase prelude + "import" →
* "Syncing… <file> (n/m)" — bare "Syncing…"
* until the import's first file (clone/pull,
* A4, byte-identical); "overview" → "Writing KB
* overview…"; "summaries" → "Summarizing
* folders… <source/folder> (n/m)" (the folder
* part omitted while current_summary is null —
* the phase's first poll) — the post-import span
* where the file count sits still;
* 2. upload running → BARE "Importing…" — the background upload
* RUN (phase 90: unpack + register only, no
* scan — its status never carries a file or
* counts; 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. sync cancelled → the phase-134 neutral terminal (task 02 —
* A1: cancel is intent, not failure): settle
* the main button retry-ready + "Sync
* cancelled" on #sync-progress, no banner / no
* modal (applySyncCancelled); the settle also
* hides the Cancel control;
* 6. upload success → settle "Sync sources" + catalog refresh
* (loadTree — phase 90: an upload no longer
* changes the KB, the re-read is a no-op safety
* net); the upload's result line lives on the
* Sources page, never in #sync-progress (A3);
* 7. upload failed → settle "Sync sources" — the failure is the
* Sources page's error banner, never this page's (A3);
* 8. both idle → retry-ready idle.
* Phase 134 (task 02) — the Cancel control (#sync-cancel-btn, the
* static markup next to #sync-btn — ship-hidden, the same
* convention): visible + enabled ONLY while a SYNC run is in
* flight. The show/hide contract has two choke points:
* enterSyncRunningState shows it for kind "sync" (the live 202/409
* click, the load-time re-attach, and every running tick of branch
* 1) and hides it for kind "upload" (the upload job is NOT
* cancellable in this phase — its run keeps the control hidden);
* settleSyncButton hides it on EVERY settle (branches 3–8 all pass
* through it). The click runs requestCancel(): the control disables
* IMMEDIATELY (one request at a time — never stale) before
* POST /api/sync/cancel; 403 → the whoami-backstop branch (stop
* polling, hide the main button, settle idle — the same depth as
* every other 403 in this module); 409 → SILENT (the run ended
* between the tick and the click — the poll settles on the next
* tick and its settle hides the control; no error copy); 202 →
* nothing (the control stays disabled — the poll's branch 5 settles
* everything; the run may already be terminal by then — the status
* object, not the request, is authoritative); any other non-2xx /
* network failure → the phase-55 neutral retry copy on
* #sync-progress (the box) + re-enable (the run is still going —
* the owner can retry).
* The live label is the status endpoint's full source/relative/path
* (A4) — or, in the post-import phases, the phase's own copy (Phase
* 98 task 02): CSS ellipsizes #sync-label; the full untruncated
* label also rides the button title (hover — set for EVERY running
* label, not just file labels) and #sync-progress (the aria-live
* announcer — screen readers hear it). The load-time re-attach
* (initSyncButton) re-enters a RUNNING sync the same way — with
* whatever phase the status reports (a mid-summaries reload shows
* the summaries label — the never-stale contract) — and a RUNNING
* upload the same way; a terminal upload is a no-op there (the
* boot-time loadTree() already shows the current catalog).
*
* Elements: #sync-btn (the button), #sync-label (the text —
* Phase 128 (task 01): the PARTS — .sync-label-main, the
* shrinks-to-fit prefix + file/folder, + .sync-label-count, the
* pinned "(n/m)" position (hidden when absent); phase 129 (task
* 04): + .sync-label-chunk, the pinned "· chunk n/m" embed-batch
* position of the in-flight file (hidden in every state but the
* import phase after the file's first batch)), #sync-cancel-btn
* (the Cancel control — phase 134 task 02, ship-hidden, revealed
* only while a sync run is in flight), #sync-icon (the
* spinner icon), #sync-progress (Phase 149 task 01 + Phase 150:
* the full untruncated live label's box under the button row —
* the SOLE sync text surface AND the sole aria-live region
* (role="status" aria-live="polite"); visible while a run is in
* flight (the live label) and after a settle (the result line —
* the counts line, "Sync cancelled", the cancel-failure copy),
* hidden only when its text is empty; the .sync-progress-text
* span carries the label),
* #sync-error-banner / #sync-error-text (error banner).
*/
const syncBtn = root.querySelector("#sync-btn");
// Phase 134 (task 02): the Cancel control — visible + enabled ONLY
// while a SYNC run is in flight (the section header carries the
// show/hide contract); the ship-hidden attribute stays until the
// first running sync state.
const syncCancelBtn = root.querySelector("#sync-cancel-btn");
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
const syncLabelMain = syncLabel ? syncLabel.querySelector(".sync-label-main") : null;
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null;
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
// Phase 149 (task 01) + Phase 150 (task 02): the sync detail box —
// the full UNTRUNCATED live label in a bordered panel under the
// button row, the SOLE sync text surface AND the sole aria-live
// region for the sync area (the previous live-region span was
// retired in phase 150 — one live region, no double announce).
// Its text + visibility ride the single setSyncProgress writer
// (the running label while a run is in flight; the settle result
// line after it — the box hides only when its text is empty).
const syncProgress = root.querySelector("#sync-progress");
const syncProgressText = syncProgress
? syncProgress.querySelector(".sync-progress-text")
: null;
const syncErrorBanner = root.querySelector("#sync-error-banner");
const syncErrorText = root.querySelector("#sync-error-text");
const SYNC_POLL_MS = 2000;
let syncPollTimer = null;
let lastSyncState = null;
function emitSyncStatus(status) {
lastSyncState = status.state;
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
}
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
function fmtSyncTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* Phase 128 (task 01) + Phase 129 (task 04): the label PARTS
* writer — the SINGLE point that writes #sync-label. mainText lands
* in the MAIN part (.sync-label-main — the shrinks-to-fit prefix +
* file/folder, the part CSS ellipsizes); countText lands in the
* COUNT part (.sync-label-count — the pinned "(n/m)" position,
* NEVER clipped); chunkText (phase 129) lands in the CHUNK part
* (.sync-label-chunk — the pinned "· chunk n/m" embed-batch
* position of the in-flight file). The COUNT and CHUNK parts share
* one contract: present → its textContent + hidden=false, absent →
* emptied + hidden=true (each span is hidden whenever there is
* nothing of its kind to show). Then the UNTRUNCATED full label —
* mainText + (countText ? " " + countText : "") + (chunkText ?
* " " + chunkText : "") — rides the button title (A4 — the screen
* reader hears the whole "Syncing… <file> (2/3) · chunk 14/38"
* there, and in the #sync-progress box — the sole aria-live
* region, phase 150 — while a run is in flight); the visible
* spans carry main + count + chunk, the spaces between them are
* the CSS gap. textContent only — the house rule, no HTML strings.
* Every label state goes through here (the running builders, the
* idle / settle labels, the 202/409 click, the re-attach).
* Phase 149 (task 01): the fourth parameter — `running` (the
* in-flight flag; the callsites are the ONLY decision sites: the
* running entry passes true, the settle passes the default false).
* Phase 150 (task 02): while running, the SAME untruncated `full`
* string goes into the box through the single box writer
* (setSyncProgress — textContent only), so the box can never
* disagree with the button title / aria; a non-running call leaves
* the box alone — the idle / settle button labels must not fill
* the panel, and the settle paths own the box's result line. The
* button's accessible name rides here too: while running the
* icon-only button (task 02 hides the label parts) reads the FULL
* live label (the title already carries it for hover); every
* non-running call restores the ship value "Sync sources" (the
* static HTML attribute is the idle truth — the JS only
* overwrites/restores it, never removes it). */
function setSyncLabel(mainText, countText = "", chunkText = "", running = false) {
if (syncLabelMain) syncLabelMain.textContent = mainText;
if (syncLabelCount) {
if (countText) {
syncLabelCount.textContent = countText;
syncLabelCount.hidden = false;
} else {
syncLabelCount.textContent = "";
syncLabelCount.hidden = true;
}
}
if (syncLabelChunk) {
if (chunkText) {
syncLabelChunk.textContent = chunkText;
syncLabelChunk.hidden = false;
} else {
syncLabelChunk.textContent = "";
syncLabelChunk.hidden = true;
}
}
const full =
mainText +
(countText ? ` ${countText}` : "") +
(chunkText ? ` ${chunkText}` : "");
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
// Phase 150 (task 02): the box — the sole sync text surface and
// the sole aria-live region — receives the running label through
// the single box writer, and ONLY while a run is in flight (a
// non-running call leaves the box alone: the idle / settle button
// labels must not fill the panel).
if (running) setSyncProgress(full);
// The running button is the spinner (task 02 hides the label
// parts) — its accessible name is the full live label; every
// settle restores the ship value (see the function docstring).
if (syncBtn) syncBtn.setAttribute("aria-label", running ? full : "Sync sources");
}
/* Phase 150 (task 02): the SINGLE writer for the #sync-progress
* box — the ONLY function that touches its text or visibility.
* The text lands on the inner #sync-progress-text span (textContent
* only — the house rule, never HTML) and the box's visibility is
* owned by this writer alone: hidden IFF the text is empty (the
* global [hidden] rule does the display). The content mode is
* decided at the callsites (the phase-149 "the callsites are the
* only decision sites" rule): the running label (setSyncLabel
* passes `full` while a run is in flight) and the settle result
* line (the counts line, "Sync cancelled", the cancel-failure
* copy; "" hides the box — the true idle, the upload settles, the
* sync failure). The box is the SOLE sync text surface and the
* sole aria-live region for the sync area — one live region, no
* double announce. */
function setSyncProgress(text) {
if (!syncProgressText || !syncProgress) return;
syncProgressText.textContent = text;
syncProgress.hidden = text === "";
}
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
* live-file label. `kind` picks the prefix — "sync" → "Syncing…",
* "upload" → "Importing" (the background run's word, A3). The
* current file — the status endpoint's full source/relative/path
* (A4) — lands in the MAIN part while one is being processed; the
* BARE prefix shows during the clone/pull (sync), before any file
* is indexed. Phase 90: the upload run is unpack + register only
* (no scan), so its status never carries a file or counts — the
* "Importing" label is always the bare one. The counts — the PINNED
* count part, never clipped (Phase 128) — appear only once the
* import has started (total > 0). The builders return the PARTS
* ({ main, count }); setSyncLabel writes them (the untruncated full
* string — main + " " + count — rides the button title +
* #sync-progress, A4). Phase 98 (task 02): the SYNC job's
* post-import phases (overview / summaries) take their own label
* via fmtSyncPhaseLabel (below) — this builder keeps that
* prelude/import fall-through byte-identical and serves the UPLOAD
* job unchanged (its status has no phase — D2). Phase 129 (task
* 04): this builder still returns NO chunk part — the pinned
* "· chunk n/m" part is added by fmtSyncPhaseLabel's import
* fall-through (the only site that sees the status's chunk
* fields), so the upload label and the 202/409 click stay bare. */
function fmtSyncLabel(kind, currentFile, done, total) {
const prefix = kind === "upload" ? "Importing" : "Syncing…";
const main = currentFile ? `${prefix} ${currentFile}` : prefix;
const count = total > 0 ? `(${done}/${total})` : "";
return { main, count };
}
/* Phase 98 (task 02, D2) + Phase 128 (task 01, the PARTS rework):
* the SYNC job's phase-aware running label — the post-import span
* where the file count sits still (the owner's "the number pauses
* for a really long time"): "overview" → "Writing KB overview…"
* (no count part); "summaries" → main "Summarizing folders…
* <source/folder>" + count "(n/m)" — the folder part is omitted
* while current_summary is null (the phase's first poll) and the
* COUNT IS ALWAYS EMITTED in this phase (the hook always carries a
* total) and the CHUNK part stays empty (the summaries phase's own
* (n/m) is its position — the status's chunk fields are cleared at
* this transition); anything else (the null prelude, "import") →
* the phase-64 fmtSyncLabel parts, byte-identical (same builder,
* same fields, same order) + the phase-129 CHUNK part: "· chunk
* n/m" ONLY while chunks_total > 0 (a file's embed batch is in
* flight — n the 1-based first chunk of the batch, m the file's
* total content chunks, batch granularity), empty before the first
* batch (0/0) so the part hides. The UPLOAD job never passes a
* status (its endpoint has no phase — D2) and keeps the bare
* fmtSyncLabel parts. The untruncated full label rides the button
* title + #sync-progress like every running label (A4 —
* setSyncLabel). */
function fmtSyncPhaseLabel(status) {
if (status.phase === "overview") {
return { main: "Writing KB overview…", count: "", chunk: "" };
}
if (status.phase === "summaries") {
const folder = status.current_summary ? ` ${status.current_summary}` : "";
return {
main: `Summarizing folders…${folder}`,
count: `(${status.summaries_done}/${status.summaries_total})`,
chunk: "", // this phase's own (n/m) is its position — no chunk part
};
}
// null phase (the prelude) or "import" — today's byte-identical
// sync label (the phase-64 contract, unchanged) + the phase-129
// chunk part (hidden before the in-flight file's first batch).
const { main, count } = fmtSyncLabel(
"sync", status.current_file, status.files_done, status.files_total
);
const chunk =
status.chunks_total > 0
? `· chunk ${status.current_chunk}/${status.chunks_total}`
: "";
return { main, count, chunk };
}
function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
return parts.join(" · ");
}
function sanitizeSyncError(message) {
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
/* 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). Phase 98 (task 02, D2): the
* SYNC job passes the status object as `status` — its running label
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
* Phase 128 (task 01): the builders return the PARTS and the single
* writer (setSyncLabel) lands them — the main part (CSS-ellipsized),
* the pinned count part (hidden when absent), and the FULL
* untruncated label on the button title + #sync-progress (A4 — set
* for EVERY running label; CSS ellipsizes the main part only).
* Phase 129 (task 04): the third, pinned CHUNK part ("· chunk n/m"
* — the in-flight file's embed-batch position) is destructured the
* same way (absent from the bare builders — the upload job and the
* 202/409 click — so the "" default keeps the part hidden there).
* Same never-stale mechanics as before (disabled, aria-busy,
* spinning icon, no is-error). Phase 149 (task 01): the writer call
* passes `running = true` — the detail box reveals with the full
* label, and the button's accessible name becomes the full live
* label (the icon-only running button keeps a name; the writer
* owns the aria write, the entry no longer sets it) — plus the
* `is-running` class, which travels with the spin (task 02's CSS
* makes the running button icon-only).
*/
function enterSyncRunningState(kind, currentFile, done, total, status) {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
const { main, count, chunk = "" } =
kind === "sync" && status
? fmtSyncPhaseLabel(status)
: fmtSyncLabel(kind, currentFile, done, total);
// the parts + the A4 untruncated title / announcer + the phase-149
// detail box (revealed, full label) + the running aria-label
setSyncLabel(main, count, chunk, true);
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
// Phase 149 (task 01): the icon-only running button — the class
// and the spin travel together (one in-flight state; the settle
// removes both).
syncBtn.classList.add("is-running");
// Phase 134 (task 02): the Cancel control is visible + enabled
// ONLY while a SYNC run is in flight — the upload job is NOT
// cancellable in this phase (its run keeps the control hidden).
if (kind === "sync") showSyncCancelBtn();
else hideSyncCancelBtn();
}
function settleSyncButton(label) {
if (!syncBtn) return;
// Phase 134 (task 02): EVERY settle hides the Cancel control —
// every terminal (sync success/failed/cancelled, upload
// success/failed) and both idle settles pass through here.
hideSyncCancelBtn();
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning");
// Phase 149 (task 01): the class travels with the spin — EVERY
// settle (every terminal + both idles pass through here) leaves
// the running state.
syncBtn.classList.remove("is-running");
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
// "Synced HH:MM") go through the parts writer — count hidden
// (absent), the label's full string rides the title (A4, as with
// every label state). Phase 150 (task 02): the default
// `running = false` leaves the detail box alone in every
// non-running state (the settle paths own the box's result line
// through setSyncProgress) and re-restores the button's idle
// accessible name.
setSyncLabel(label);
}
function showSyncError(detail) {
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
if (syncErrorBanner) syncErrorBanner.hidden = false;
}
function hideSyncError() {
if (syncErrorText) syncErrorText.textContent = "";
if (syncErrorBanner) syncErrorBanner.hidden = true;
}
/* Phase 134 (task 02): the Cancel control's show/hide — visible +
* enabled ONLY while a SYNC run is in flight (the upload job is NOT
* cancellable in this phase). Show = unhide + enable + the aria
* label (the accessible name stays stable — the visible "Cancel"
* text is the WCAG 2.4 name); hide = hide + disable, so a hidden
* control is never focusable or activatable in any state (the
* never-stale contract — the settle paths below are the only hides,
* and they run on every terminal). */
function showSyncCancelBtn() {
if (!syncCancelBtn) return;
syncCancelBtn.hidden = false;
syncCancelBtn.disabled = false;
syncCancelBtn.setAttribute("aria-label", "Cancel sync");
}
function hideSyncCancelBtn() {
if (!syncCancelBtn) return;
syncCancelBtn.hidden = true;
syncCancelBtn.disabled = true;
}
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
let syncModal = null;
let syncModalReturnFocus = null;
function createSyncModal() {
const backdrop = document.createElement("div");
backdrop.className = "sync-modal-backdrop";
backdrop.innerHTML =
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
'<h2 id="sync-modal-title">Sync failed</h2>' +
'<p id="sync-modal-error"></p>' +
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
"</div>";
document.body.appendChild(backdrop);
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeSyncModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
});
return backdrop;
}
function showSyncModal(error) {
if (!syncBtn || !document.body) return;
if (!syncModal) syncModal = createSyncModal();
syncModal.querySelector("#sync-modal-error").textContent = error;
if (syncModal.classList.contains("is-open")) return;
const active = document.activeElement;
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
syncModal.classList.add("is-open");
syncModal.querySelector(".sync-modal-close").focus();
}
function closeSyncModal() {
if (!syncModal || !syncModal.classList.contains("is-open")) return;
syncModal.classList.remove("is-open");
const target = syncModalReturnFocus;
syncModalReturnFocus = null;
if (target && document.contains(target)) target.focus();
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
// Phase 150 (task 02): the settle's counts line IS the box's
// result line (the sole live region) — the string builder is
// unchanged (A2: the "Synced HH:MM" label stays on the button).
setSyncProgress(fmtSyncResult(status.detail));
hideSyncError();
emitSyncStatus(status);
// Refresh the catalog live — the KB just changed.
loadTree();
}
function applySyncFailure(status) {
const error = sanitizeSyncError(status.error);
settleSyncButton("Sync sources");
if (syncBtn) {
syncBtn.title = error;
syncBtn.setAttribute("aria-label", error);
syncBtn.classList.add("is-error");
}
// Phase 150 (task 02): the error goes to the banner + the modal —
// the box hides, empty (nothing to announce in the live region).
setSyncProgress("");
showSyncError(error);
emitSyncStatus(status);
showSyncModal(error);
}
/* Phase 134 (task 02): the cancelled terminal — the NEUTRAL result
* (A1: cancel is intent, not failure — no error banner, no modal,
* no error styling): settle the main button retry-ready (which also
* hides the Cancel control), announce "Sync cancelled" on the
* aria-live box (#sync-progress — the sole live region, phase
* 150), hide the error banner (a failed run's banner must not
* survive the cancel), and emit the status frame (the
* bor:sync-status detail is the status object, as for every
* state). Phase 150 (task 02): the settle's "Sync sources" label
* stays on the button only (the non-running setSyncLabel call no
* longer writes the box) — the box shows the result line, nothing
* else. */
function applySyncCancelled(status) {
settleSyncButton("Sync sources");
setSyncProgress("Sync cancelled"); // the box's result line (phase 150)
hideSyncError();
emitSyncStatus(status);
}
function applySyncIdle(status) {
settleSyncButton("Sync sources");
// Phase 150 (task 02): the true idle — retry-ready, nothing to
// announce: the box (the sole live region) hides, empty.
setSyncProgress("");
emitSyncStatus(status || { state: "idle" });
}
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
* sync AND the background upload run (phase 90: unpack + register,
* no 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 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) syncStatus = await r.json();
} catch { /* network blip — retry next tick */ }
if (notAdmin) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
// The SECOND job: the background upload run (phase 90: unpack +
// register only — no 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;
}
// 1. sync running: the phase-aware label (phase 98 task 02 — the
// null prelude + "import" keep the byte-identical file label,
// "overview" / "summaries" name themselves; the WHOLE status
// goes in for the phase fields).
if (syncStatus.state === "running") {
enterSyncRunningState(
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total,
syncStatus
);
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
// 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 (syncStatus.state === "success") {
stopSyncPolling();
applySyncSuccess(syncStatus);
return;
}
if (syncStatus.state === "failed") {
stopSyncPolling();
applySyncFailure(syncStatus);
return;
}
// 5. sync cancelled: the NEUTRAL terminal (phase 134 task 02 —
// A1: cancel is intent, not failure — no banner, no modal):
// the "Sync cancelled" result line + the retry-ready main
// button; the settle also hides the Cancel control.
if (syncStatus.state === "cancelled") {
stopSyncPolling();
applySyncCancelled(syncStatus);
return;
}
// 6. upload success: settle + catalog refresh (A3 — the upload's
// counts live on the Sources page; #sync-progress stays
// empty + hidden).
if (uploadStatus && uploadStatus.state === "success") {
stopSyncPolling();
settleSyncButton("Sync sources");
setSyncProgress(""); // the upload settle: the box hides (phase 150)
hideSyncError();
emitSyncStatus({ state: "idle" });
loadTree();
return;
}
// 7. 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");
setSyncProgress(""); // the upload settle: the box hides (phase 150)
hideSyncError();
emitSyncStatus({ state: "idle" });
return;
}
// 8. both idle: settle retry-ready.
stopSyncPolling();
applySyncIdle(syncStatus);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
applySyncFailure({
state: "failed",
error: "Could not reach the server to start the sync — try again.",
});
return;
}
if (r.status === 403) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
// Phase 64: the run is just starting (model check / clone-pull) —
// bare "Syncing…" until the first polled file (A4); entering the
// running state reveals #sync-progress with the same label
// (phase 150: the box IS the announcer).
enterSyncRunningState("sync", null, 0, 0);
hideSyncError();
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
let detail = "";
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
applySyncFailure({
state: "failed",
error: detail || `The server refused to start the sync (${r.status}).`,
});
}
/* Phase 134 (task 02): the cancel request — the §7.4 never-stale
* contract applies exactly as to the main button: the control
* disables IMMEDIATELY (one request at a time — no double fire)
* BEFORE the fetch. Phase 150 (task 02): there is no pre-fetch box
* clear — the run is still in flight at that moment, so blanking
* would hide the running surface for up to one 2 s poll; the
* poll's `cancelled` branch settles the box, and the control's
* immediate disable keeps the never-stale contract.
* • 403 → the whoami-backstop branch (stop polling, hide the
* main button, settle idle) — the same depth as every
* other 403 in this module;
* • 409 → NO-OP: the run ended between the tick and the click —
* the poll settles on the next tick (its settle hides
* the control); no error copy, no state change;
* • 202 → nothing: the control stays disabled — the poll's
* `cancelled` branch settles everything (the run may
* already be terminal by the next tick — the status
* object, not the request, is authoritative);
* • any other non-2xx / network failure → the phase-55 neutral
* retry copy on #sync-progress (the box) + the control
* re-enables (the run is still going — the owner can retry). */
async function requestCancel() {
if (!syncCancelBtn) return;
syncCancelBtn.disabled = true; // FIRST — never stale, one request at a time
// Phase 150 (task 02): the pre-fetch clear is dropped — a NO-OP on
// the box. The run is still in flight at this moment, so blanking
// would hide the running surface for up to one 2 s poll; the
// poll's `cancelled` branch settles it. The never-stale contract
// is kept by the control's immediate disable above.
let r;
try {
r = await fetch("/api/sync/cancel", { method: "POST" });
} catch {
// Network failure: the run is still going — the phase-55
// neutral retry copy + the control re-enables (retryable).
setSyncProgress("Couldn't cancel the sync — try again.");
syncCancelBtn.disabled = false;
return;
}
if (r.status === 403) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 409) return; // race: the run ended — the poll settles
if (r.ok) return; // 202: the poll's cancelled branch settles everything
setSyncProgress("Couldn't cancel the sync — try again.");
syncCancelBtn.disabled = false; // the run is still going — retry
}
/* 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 RUN (phase 90: unpack
* + register — the bare "Importing…" label) adopts the button the
* same way — the "user clicked upload, then opened sources" case; a
* terminal upload is a no-op (the boot-time loadTree() already shows
* the current catalog). Phase 98 (task 02): a RUNNING sync re-enters
* with the WHOLE status — the label is phase-aware, so a mid-
* summaries reload re-enters with the summaries label (the
* never-stale contract); a RUNNING upload re-attach stays on the
* bare label (its status has no phase — D2). */
async function initSyncButton() {
if (!syncBtn) return;
if (!(await fetchIsAdmin())) return;
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) { syncBtn.hidden = true; return; }
if (!r.ok) return;
status = await r.json();
} catch { return; }
if (status.state === "running") {
enterSyncRunningState(
"sync", status.current_file, status.files_done, status.files_total,
status
);
emitSyncStatus(status);
startSyncPolling();
return;
}
if (status.state === "success") {
applySyncSuccess(status);
return;
}
if (status.state === "failed") {
applySyncFailure(status);
return;
}
// Phase 134 (task 02): a reload during/right after a cancel shows
// the neutral result — never a stuck running state.
if (status.state === "cancelled") {
applySyncCancelled(status);
return;
}
// Sync idle: check the SECOND job — an in-flight upload run
// re-attaches (the bare "Importing…" label — phase 90: unpack +
// register only).
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) {
syncBtn.addEventListener("click", startSync);
initSyncButton();
}
// Phase 134 (task 02): the Cancel control — armed next to the main
// button (the control is visible only while a sync run is in
// flight, so it can never be reached in any other state).
if (syncCancelBtn) {
syncCancelBtn.addEventListener("click", requestCancel);
}
const tbody = root.querySelector("#docs-tbody");
const emptyEl = root.querySelector("#sources-empty");
/* Phase 97 (task 04): the new #folders-wrap ALSO carries the shared
* .table-wrap card class — the FILE table's wrap is therefore looked
* up through its own table, not the class (a class lookup would hit
* #folders-wrap first in document order). */
const tableWrap = root.querySelector("#docs-table").parentElement;
const statCards = root.querySelector("#stat-cards");
const gateEl = root.querySelector("#sources-gate");
const statDocs = root.querySelector("#stat-docs");
const statChunks = root.querySelector("#stat-chunks");
const statLast = root.querySelector("#stat-last");
const crumbEl = root.querySelector("#kb-crumb");
const levelEl = root.querySelector("#kb-level");
const levelTitleEl = root.querySelector("#kb-level-title");
const levelSummaryEl = root.querySelector("#kb-level-summary");
const foldersWrap = root.querySelector("#folders-wrap");
const foldersTbody = root.querySelector("#folders-tbody");
/* Phase 97 (task 05): the level block's static Edit button + the
* .kb-level-body that holds the description UI (the <p> + the
* button) — the shared editor swaps INSIDE the body, leaving the
* block's <h2> untouched. */
const levelEditBtn = root.querySelector("#kb-level-edit");
const levelBody = levelEl ? levelEl.querySelector(".kb-level-body") : null;
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
* catalog is admin-only. The document viewer itself stays public (the
* soft rule), so the gate copy points at what keeps working.
* Phase 76 (task 02): the whoami request is the shared header module's
* cached promise — fetchIsAdmin(), the same single request the shell's
* header boots (zero extra requests). */
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
/* Phase 97 (task 04): the drill-down tree (the module docstring
carries the full contract). `kbTree` = the last fetched tree;
`current` = the drill state ({ null, null } = top, `folder: ""`
= the source root). Phase 79 carries over: the monotonic loadSeq
race token invalidates an in-flight load the moment a newer one
starts — only the newest load may touch the DOM after its await
(the boot re-attach (applySyncSuccess → loadTree) and the boot-time
loadTree interleave exactly as the flat load once did). Re-entrancy
moves from the fetch top into the render: renderLevel() clears
BOTH row containers BEFORE filling them (the phase-77 History
pattern, extended to the second container), so a refresh from a
populated level into a sparser one leaves no ghost rows. */
let kbTree = { sources: [] };
let current = { source: null, folder: null };
let loadSeq = 0;
let levelEditor = null; // phase 97 (task 05): the level editor handle
async function loadTree() {
const my = ++loadSeq;
let r;
try {
r = await fetch("/api/docs/tree");
} catch {
if (my === loadSeq) renderEmpty();
return;
}
if (!r.ok) {
if (my === loadSeq) renderEmpty();
return;
}
const tree = await r.json();
if (my !== loadSeq) return; // a newer load owns the DOM now
kbTree = tree && Array.isArray(tree.sources) ? tree : { sources: [] };
resetVanishedLocation();
renderLevel();
}
/* Never-stale (PLAN §7.4, phase 97): after a re-fetch, the drilled
* location may no longer exist in the NEW tree (the source was
* unregistered/pruned, the folder vanished) — reset to the top level
* BEFORE rendering: no stale breadcrumb, no stale block. */
function resetVanishedLocation() {
if (current.source === null) return;
const src = kbTree.sources.find((s) => s.name === current.source);
if (!src) {
current = { source: null, folder: null };
return;
}
if (current.folder !== null && !folderExistsIn(src, current.folder)) {
current = { source: null, folder: null };
}
}
/* The phase-94 existence rule, mirrored client-side over the tree's
* file paths: a folder exists under the source iff some of the
* source's indexed paths starts with `folder + "/"` (the source root
* — `""` — always exists). */
function folderExistsIn(src, folderPath) {
if (folderPath === "") return true;
const paths = [];
const collect = (node) => {
for (const child of node.children || []) {
if (child.kind === "file") paths.push(child.path);
else collect(child);
}
};
collect(src);
return paths.some((p) => p.startsWith(folderPath + "/"));
}
/* The node for `current`: the source node at the root, or the folder
* node found by walking the tree with the folder's cumulative
* source-relative path (the builder's folder paths are
* source-relative, so `one/two` resolves one level at a time). */
function currentLevelNode() {
const src = kbTree.sources.find((s) => s.name === current.source);
if (!src) return null;
if (current.folder === null || current.folder === "") return src;
let node = src;
let acc = "";
for (const part of current.folder.split("/")) {
acc = acc ? acc + "/" + part : part;
const next = (node.children || []).find(
(c) => c.kind === "folder" && c.path === acc
);
if (!next) return null;
node = next;
}
return node;
}
/* The KB-wide stat cards (phase 97): walk the WHOLE tree — document
* count, chunks sum, max indexed_at — the values identical to the
* former flat /api/docs walk (the same documents, one level deeper).
* fmtDate reuse for the max. */
function treeStats() {
let docs = 0;
let totalChunks = 0;
let last = "";
const walk = (node) => {
for (const child of node.children || []) {
if (child.kind === "file") {
docs += 1;
totalChunks += child.chunks;
if (child.indexed_at > last) last = child.indexed_at;
} else {
walk(child);
}
}
};
for (const s of kbTree.sources) walk(s);
return { docs, totalChunks, last };
}
/* The breadcrumb (phase 97): hidden at the top level; when drilled
* in, one link per ancestor — the top level (back to the sources
* list), the source, then the folder chain — the LAST segment a
* span with aria-current="page". Client-side only: no fetch, no URL
* change (the navigation is a re-render of the fetched tree). */
function renderCrumb() {
if (!crumbEl) return;
crumbEl.replaceChildren();
if (current.source === null) {
crumbEl.hidden = true;
return;
}
crumbEl.hidden = false;
let appended = false;
const append = (el) => {
if (appended) {
const sep = document.createElement("span");
sep.className = "kb-crumb-sep";
sep.setAttribute("aria-hidden", "true");
sep.textContent = "/";
crumbEl.appendChild(sep);
}
crumbEl.appendChild(el);
appended = true;
};
append(crumbSegment("Knowledge base", { source: null, folder: null }));
if (current.folder === null || current.folder === "") {
append(crumbCurrent(current.source));
} else {
append(crumbSegment(current.source, { source: current.source, folder: "" }));
let acc = "";
const parts = current.folder.split("/");
parts.forEach((part, i) => {
acc = acc ? acc + "/" + part : part;
if (i === parts.length - 1) append(crumbCurrent(part));
else append(crumbSegment(part, { source: current.source, folder: acc }));
});
}
}
function crumbSegment(label, target) {
const a = document.createElement("a");
a.className = "kb-crumb-link";
a.href = "#"; // client-side navigation only — no URL change
a.textContent = label; // document-derived text — never innerHTML
a.addEventListener("click", (e) => {
e.preventDefault();
goTo(target);
});
return a;
}
function crumbCurrent(label) {
const span = document.createElement("span");
span.className = "kb-crumb-current";
span.setAttribute("aria-current", "page");
span.textContent = label; // document-derived text — never innerHTML
return span;
}
/* The drill navigation (phase 99, task 02, D2 — client-side, no
* fetch; the drill state IS the history state). `applyTarget(target,
* push)`: when `push` is true the target DIFFERS from the current
* level (a re-click of the current row/segment pushes NO duplicate
* entry), it FIRST records a STATE-ONLY entry — history.pushState(
* { view: "rag", kb: target }, "") — the second arg "" + no third:
* the URL stays the shell's pathname (no new route, the phase-76
* deep-link surface is untouched) — then sets `current` +
* re-renders. Back/forward (popstate) and the re-show alignment
* (bor:view-refresh) adopt with push=false: they never push (the
* browser owns those entries). */
function applyTarget(target, push) {
if (push && (target.source !== current.source || target.folder !== current.folder)) {
history.pushState({ view: "rag", kb: target }, "");
}
current = { source: target.source, folder: target.folder };
renderLevel();
}
/* Every user-initiated drill (source row, folder link, breadcrumb
* segment) — push a history entry, then drill (D2, phase 99 task
* 02). */
function goTo(target) {
applyTarget(target, true);
}
/* ONE table for every level (phase 97): at the TOP level the rows
* are the SOURCES themselves (the ls() equivalence — name, recursive
* count, the stored (source, "") description). textContent only. */
function makeSourceRow(s) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
const link = document.createElement("a");
link.className = "folder-link";
link.href = "#"; // client-side drill — no URL change
link.title = s.name; // hover name (the cell may ellipsize)
link.textContent = s.name; // document-derived text — never innerHTML
link.addEventListener("click", (e) => {
e.preventDefault();
goTo({ source: s.name, folder: "" });
});
nameTd.appendChild(link);
tr.appendChild(nameTd);
const countTd = document.createElement("td");
countTd.textContent = String(s.documents);
tr.appendChild(countTd);
// Phase 106 (task 08, D8/D9): the Updated cell — the source's
// subtree MAX document created_at (the tree API derives it),
// BETWEEN the count and the Description (the owner's verbatim
// position). A 0-document source has none → the statLast "–" null
// idiom; the ISO value rides the title (hover precision — the
// makeRow path-cell idiom).
const updatedTd = document.createElement("td");
updatedTd.textContent = s.updated_at ? fmtDate(s.updated_at) : "–";
if (s.updated_at) updatedTd.title = s.updated_at; // full ISO on hover
tr.appendChild(updatedTd);
tr.appendChild(makeDescCell(s, s.name, "", s.name)); // Description + ALWAYS-present Edit (task 05)
return tr;
}
/* A level's subfolder row: the folder's LAST path segment as the
* label (the full source-relative path rides the title — the cell
* ellipsizes), the recursive count, the stored description (AI or
* manual — any row) or an empty cell. textContent only. */
function makeFolderRow(f) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
const link = document.createElement("a");
link.className = "folder-link";
link.href = "#"; // client-side drill — no URL change
link.title = current.source + "/" + f.path; // full path on hover
link.textContent = f.path.split("/").pop(); // never innerHTML
link.addEventListener("click", (e) => {
e.preventDefault();
goTo({ source: current.source, folder: f.path });
});
nameTd.appendChild(link);
tr.appendChild(nameTd);
const countTd = document.createElement("td");
countTd.textContent = String(f.documents);
tr.appendChild(countTd);
// Phase 106 (task 08, D8/D9): the Updated cell — the folder's
// subtree MAX document created_at (the tree API derives it),
// BETWEEN the count and the Description (the owner's verbatim
// position); the ISO value rides the title (hover precision — the
// makeRow path-cell idiom). The "–" branch is defensive: a folder
// node always has ≥1 document, so its max is never null (a
// 0-document source IS the null case, makeSourceRow).
const updatedTd = document.createElement("td");
updatedTd.textContent = f.updated_at ? fmtDate(f.updated_at) : "–";
if (f.updated_at) updatedTd.title = f.updated_at; // full ISO on hover
tr.appendChild(updatedTd);
tr.appendChild(
makeDescCell(f, current.source, f.path, current.source + "/" + f.path)
); // Description + ALWAYS-present Edit (task 05)
return tr;
}
/* Render the CURRENT level from `current` + `kbTree` (phase 97).
* Clears BOTH row containers FIRST (re-entrancy — no ghost rows),
* then: zero sources → the no-data state; top level → the source
* rows (level block + file table hidden); inside a source/folder →
* the breadcrumb, the level block (the level's stored description —
* hidden when none is stored, the ls rule), the direct subfolders,
* and the direct files (makeRow, unchanged — the file node carries
* no source, the row object restores the flat shape makeRow reads). */
function renderLevel() {
if (foldersTbody) foldersTbody.replaceChildren();
if (tbody) tbody.replaceChildren();
if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4)
if (!kbTree.sources.length) {
renderEmpty();
return;
}
emptyEl.hidden = true;
const st = treeStats();
statDocs.textContent = String(st.docs);
statChunks.textContent = String(st.totalChunks);
statLast.textContent = st.last ? fmtDate(st.last) : "–";
renderCrumb();
if (current.source === null) {
// Top level: the level block is hidden (nothing above the
// sources); the folders table lists the SOURCES themselves and
// the file table is ALWAYS hidden (files are seen per source,
// as with ls(source)).
levelEl.hidden = true;
for (const s of kbTree.sources) foldersTbody.appendChild(makeSourceRow(s));
foldersWrap.hidden = false;
if (tableWrap) tableWrap.hidden = true;
return;
}
const node = currentLevelNode();
if (node === null) {
// Defensive: the reset above guarantees the level exists in the
// tree this render reads — a null means the tree changed under
// us, in which case the top level is the honest view.
current = { source: null, folder: null };
renderLevel();
return;
}
// The level block: the current level's STORED description (source:
// the (source, "") row; folder: its row) — OR, since Phase 98
// (task 04, D4), the PENDING note when the level is summary_pending
// (the summary is due but not stored yet — the next sync's gap-fill
// will generate it, or the owner can write one now via the block's
// Edit button). Hidden only when NEITHER is stored nor pending
// (the ls rule: count only, no placeholder). Title = the full
// source-relative path (e.g. `alpha/two`).
if (node.summary || node.summary_pending) {
levelTitleEl.textContent = current.folder
? current.source + "/" + current.folder
: current.source;
levelSummaryEl.textContent = node.summary ||
"No description stored yet — the next sync will generate one. (You can write one yourself.)";
// Phase 98 (D4): the pending text is the muted marker style —
// and the block is REUSED across levels, so a stored level must
// clear a previous pending level's class (the same state
// closeEditor manages on the editor's close).
levelSummaryEl.className = node.summary ? "" : "kb-summary-pending";
levelEl.hidden = false;
} else {
levelEl.hidden = true;
}
const children = node.children || [];
const subfolders = children.filter((c) => c.kind === "folder");
const files = children.filter((c) => c.kind === "file");
for (const f of subfolders) foldersTbody.appendChild(makeFolderRow(f));
foldersWrap.hidden = subfolders.length === 0;
for (const f of files) {
tbody.appendChild(
makeRow({
source: current.source,
path: f.path,
title: f.title,
chunks: f.chunks,
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
indexed_at: f.indexed_at,
// Phase 122 (task 04): the image affordance — the tree's
// file node carries is_image / image_url / summary on an
// image doc (the omission rule: a TEXT node's wire shape
// carries none, so these stay undefined there and makeRow
// keeps the bare-link cell, byte-identical to pre-phase).
is_image: f.is_image,
image_url: f.image_url,
summary: f.summary,
})
);
}
if (tableWrap) tableWrap.hidden = files.length === 0;
}
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
* fixed thumbnail box (a failed fetch, or a node with no servable
* image_url). Static SVG, aria-hidden (the img's alt is the
* accessible content; this is decoration for the box). Built with
* createElementNS — the module keeps its ONE innerHTML (the static
* sync-modal skeleton, the test_kb_tree_ui pin). */
function docThumbGlyph() {
const span = document.createElement("span");
span.className = "kb-doc-thumb-glyph";
span.setAttribute("aria-hidden", "true");
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 48 48");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2.4");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
const sheet = document.createElementNS(NS, "path");
sheet.setAttribute(
"d",
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
);
const fold = document.createElementNS(NS, "path");
fold.setAttribute("d", "M28 4v8h8");
svg.append(sheet, fold);
span.appendChild(svg);
return span;
}
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
* the vision description; a NULL/blank summary falls back to the
* title, then the path). A failed fetch swaps in the document
* glyph in the SAME box (progressive enhancement — no layout shift
* beyond the fixed box, no broken-image placeholder). Called only
* for image rows (makeRow gates on d.is_image). */
function docThumb(d) {
const box = document.createElement("span");
box.className = "kb-doc-thumb";
const alt =
typeof d.summary === "string" && d.summary.trim() !== ""
? d.summary
: d.title || d.path;
if (d.image_url) {
const img = document.createElement("img");
img.className = "kb-doc-thumb-img";
img.loading = "lazy";
img.src = d.image_url;
img.alt = alt;
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
box.appendChild(img);
} else {
box.appendChild(docThumbGlyph());
}
return box;
}
/* The no-data state (phase 97): zero sources (nothing registered,
* nothing indexed) OR a failed tree fetch (the former showEmpty
* failure behavior, unchanged in kind) — every catalog surface
* hidden, the stat cards read zero. A registered 0-document source
* does NOT land here: it renders its `0 documents` row (the ls
* invariant — the deliberate semantic change, module docstring). */
function renderEmpty() {
if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4)
statDocs.textContent = "0";
statChunks.textContent = "0";
statLast.textContent = "–";
if (crumbEl) crumbEl.hidden = true;
levelEl.hidden = true;
if (foldersTbody) foldersTbody.replaceChildren();
if (tbody) tbody.replaceChildren();
if (foldersWrap) foldersWrap.hidden = true;
if (tableWrap) tableWrap.hidden = true;
emptyEl.hidden = false;
}
function makeRow(d) {
const tr = document.createElement("tr");
const sourceTd = document.createElement("td");
sourceTd.textContent = d.source; // document-derived text — never innerHTML
tr.appendChild(sourceTd);
// Path cell: a link to the document (phase 10), full path as the
// accessible/hover name (the column is ellipsized). Phase 26: the
// left-click opens the same-page modal — no new tab (document-modal.js);
// the href stays as the no-JS / context-menu escape hatch.
const pathTd = document.createElement("td");
pathTd.title = d.path; // full path on hover (column is ellipsized)
const link = document.createElement("a");
link.className = "doc-link";
link.href = documentUrl(d.source, d.path);
link.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(d.source, d.path, link);
});
link.title = d.path; // full path as the link's hover/accessible name
link.textContent = d.path;
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
// box before the path link (the .kb-doc-path flex wrapper — the
// link keeps its ellipsis). Text rows keep the bare-link cell,
// byte-identical to pre-phase (no box at all).
if (d.is_image) {
const pathWrap = document.createElement("div");
pathWrap.className = "kb-doc-path";
pathWrap.append(docThumb(d), link);
pathTd.appendChild(pathWrap);
} else {
pathTd.appendChild(link);
}
tr.appendChild(pathTd);
// Phase 106 (task 08, D8): the cell order is [title, chunks,
// created, indexed] — the Created cell lands BEFORE Indexed (the
// owner's verbatim position). The date cells are built EXPLICITLY
// (the plain-td loop can't carry per-cell titles): textContent is
// the locale date (fmtDate — the Indexed idiom), and the Created
// cell carries the FULL ISO value as its title (hover precision —
// the path-cell idiom; the E2E asserts on the locale-stable title,
// not on the toLocaleString output).
for (const value of [d.title, String(d.chunks)]) {
const td = document.createElement("td");
td.textContent = value;
tr.appendChild(td);
}
const createdTd = document.createElement("td");
createdTd.textContent = fmtDate(d.created_at);
createdTd.title = d.created_at; // full ISO on hover (locale-stable)
tr.appendChild(createdTd);
const indexedTd = document.createElement("td");
indexedTd.textContent = fmtDate(d.indexed_at);
tr.appendChild(indexedTd);
return tr;
}
/* Phase 97 (task 05): wire the LEVEL block's editor (the static
* #kb-level-edit button). The level block is PERSISTENT — reused for
* whichever level is current — so the target is a GETTER (a row's is
* a constant): it reads `current` + currentLevelNode() at open/save
* time. onCleared hides the whole block (the ls rule: no description
* → no block). The handle's reset() runs on every re-render so a
* navigate-away / refresh never leaves a stale open editor (§7.4). */
if (levelEditBtn && levelBody) {
levelEditor = wireDescriptionEdit({
editBtn: levelEditBtn,
textEl: levelSummaryEl,
container: levelBody,
getTarget: () => {
const node = currentLevelNode();
return node ? { node, source: current.source, folder: current.folder } : null;
},
// Phase 98 (D4): the level's pending display (the D4 note — no
// tooltip on the level's <p>: D4's title is the row cell's).
pendingText:
"No description stored yet — the next sync will generate one. (You can write one yourself.)",
onCleared: () => {
// The phase-57 announcement beat: keep the block visible a short
// beat so the role=status "Description cleared." is still
// readable, THEN hide it (the ls rule: no description → no
// block) — GUARDED: if a newer level is current by then (a
// navigate-away first) or its node has a description again (a
// re-create within the beat), the hide is a no-op.
setTimeout(() => {
const n = currentLevelNode();
if (n && !n.summary) levelEl.hidden = true;
}, 2000);
},
});
}
/* ---------- view boot (phase 76 task 02) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* admin gate reads fetchIsAdmin() — the SAME cached whoami promise
* header.js exports (zero extra requests): the sync button joins
* the admin reveal on that one whoami (no extra fetch), and the
* anonymous branch gates the catalog in / out with NO
* /api/docs/tree request at all (the phase-16 soft rule — the
* tree IS the catalog; phase 97). */
const admin = await fetchIsAdmin();
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
if (!admin) {
// Anonymous: gate in, catalog out, and no /api/docs/tree request
// at all — the tree surfaces ship hidden and never fill.
if (statCards) statCards.hidden = true;
if (foldersWrap) foldersWrap.hidden = true;
if (tableWrap) tableWrap.hidden = true;
if (emptyEl) emptyEl.hidden = true;
if (gateEl) gateEl.hidden = false;
return;
}
if (gateEl) gateEl.hidden = true;
/* Phase 77 (task 02) + phase 97 (task 04) + phase 99 (task 02,
D2): a user-initiated re-show of this already-mounted view
makes the router dispatch bor:view-refresh on the section —
FIRST align the drill state with the history state (a
kb-carrying entry KEEPS the drill — the active-link re-click
pushed nothing, the drilled entry is still on top; a kb-less
entry — a fresh nav visit — starts at the TOP level), THEN
re-load the catalog tree (loadTree is race-tokened: only the
newest load touches the DOM). Armed ONLY here, after the whoami
gate passed: anonymous shows the gate and must never fetch
/api/docs/tree (the phase-16 soft rule the story E2E pins). */
root.addEventListener("bor:view-refresh", () => {
const kb = history.state && history.state.kb;
if (kb) applyTarget(kb, false);
else applyTarget({ source: null, folder: null }, false);
loadTree();
});
/* Phase 99 (task 02, D2): the browser back/forward walks the
breadcrumb — a popstate ADOPTS the popped entry's kb via the
no-push variant (the browser owns its entries — adopt/reset
never push) or, for a kb-LESS entry (the boot entry, the
router's view entries, any foreign state), RESETS the drill to
the TOP level. The router's own popstate (registered earlier,
at shell boot) reads the pathname only: rag-internal entries
never change it, so its listener stays a no-op and THIS
listener's render is the visible one; for a foreign entry it
has already switched the visible view, so this render is a
harmless hidden one. Back at the top level pops the router's
view entry — the view leaves, exactly as today. Armed ONCE
here, admin branch only: the anonymous gate installs no
listeners. */
window.addEventListener("popstate", (event) => {
const kb = event.state && event.state.kb;
if (kb) applyTarget(kb, false);
else applyTarget({ source: null, folder: null }, false);
});
loadTree();
}