phase: 191_source_name_labels_tags
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s

All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-19 06:13:32 -04:00
parent c6ffa050a6
commit 4f20c256d7
70 changed files with 7378 additions and 50 deletions
+540
View File
@@ -228,6 +228,36 @@
* token-free with no per-site change; the UI must never re-embed a
* credential.
*
* Phase 191 (task 03) — the per-source name / label / tags editor (the
* owner-visible half of "Sources need a name, a label, tag(s) …"): a
* SECOND list on the page, its own card below the sources table, whose
* ROW LIST is the phase-191 catalogue GET /api/source-labels (task 02) —
* the union of every registry row's DERIVED source name and every indexed
* document source — so every kind (git, local, upload) gets an editor
* without a second API (loadLabels → renderLabelRows → makeLabelRow, the
* page's ONE <template id="source-label-row-template"> cloned per row).
* The mono identity cell is the derived name, textContent-only and never
* an input: a label row is an ALIAS (D1/A1) — renaming here touches no
* document, citation, KB-tree node, saved chat or source/path locator.
* Each row expands (aria-expanded + aria-controls) into the three fields
* (Name ≤120 / Label ≤200 / the Tags token input — Enter or comma adds a
* chip, Backspace drops the last, ≤10 chips of ≤32 chars, the caps of the
* API's normalize_tags mirrored so an over-long submission never
* round-trips as a 422) + Save / Reset + the row's own §7.4 save-state
* line (role=status): dirty → "unsaved changes" + the .is-dirty row mark
* → "Saving…"/"Resetting…" → "saved" or the ONE-LINE error note, whose
* 404 arm names itself ("That source no longer exists") because a retry
* cannot help — the source left the catalogue. ONE in-flight request per
* ROW (the phase-168 saveInFlight pattern scoped per row — a second click
* while a request is out is ignored); the PUT / DELETE response is the
* new truth (no second GET), Reset clears the fields and the stored label,
* and a collapse discards the draft instead of leaving the row claiming
* something the server never got. The fetch sits inside the existing
* admin-gated content reveal (the page's gate is this list's gate) and
* every source-list change (add / remove / a finished upload) refreshes
* it, so a new source is nameable without a reload. The existing sync /
* upload / git-row flows are untouched — the editor is purely additive.
*
* Phase 135 (task 04) — the branch pin (the field, the tri-state
* asymmetry, the tag rule): the add form gains the optional
* `#git-source-branch` (maxlength 200, autocomplete=off, the visible
@@ -354,6 +384,15 @@ export async function mount(root) {
(the checkbox is a table-cell control — no dialog of its own —
so its failure announces here, next to the table). */
const hiddenErrorEl = root.querySelector("#git-sources-hidden-error");
/* Phase 191 (task 03): the name/label/tags catalogue — this page's
SECOND list (its own endpoint, its own union of every source kind),
rendered into its own bounded table card. The template is the ONE
row skeleton cloned per source (makeLabelRow). */
const labelsErrorEl = root.querySelector("#source-labels-error");
const labelsWrap = root.querySelector("#source-labels-wrap");
const labelsTbody = root.querySelector("#source-labels-tbody");
const labelsEmptyEl = root.querySelector("#source-labels-empty");
const labelRowTemplate = root.querySelector("#source-label-row-template");
/* Polite live region: the screen-reader confirmation for loads, adds,
and removals (the phase-15 announcer pattern). */
@@ -724,6 +763,7 @@ export async function mount(root) {
// app-managed files).
closeRemoveConfirm(); // focus returns to the row's Remove button
await loadSources(); // the row leaves the table
await loadLabels(); // phase 191: and its label row leaves with it
// The removal confirmation is the LAST announcement: the
// reload's "N sources listed." must not overwrite it (the
// announcer is the screen-reader confirmation for the
@@ -1013,6 +1053,497 @@ export async function mount(root) {
if (ignoreBackdrop) ignoreBackdrop.addEventListener("click", cancelIgnoreEditor);
if (ignoreSaveBtn) ignoreSaveBtn.addEventListener("click", saveIgnorePaths);
/* ---------- source names, labels and tags (/api/source-labels) — phase 191 task 03 ----------
* TODO.md L4: "Sources need a name, a label, tag(s) so you can focus the
* model when prompting if you have a project name or a standard name you
* reference things by." Tasks 01/02 stored and exposed that; this is the
* surface the owner types into.
*
* WHY A SECOND LIST (and not a column of #git-sources-table): the row list
* comes from GET /api/source-labels — the union of (a) every registry row's
* DERIVED source name and (b) every indexed document source — so a source
* is nameable even when it has no git_sources row of its own, and the page
* never has to guess the server's naming (the importer's derivation is the
* ONE owner, app/rag/source_removal.resolve_source_name). One extra fetch,
* inside the admin branch only (the anonymous path returns at the gate and
* this file never asks).
*
* WHY THE IDENTITY CELL IS READ-ONLY (D1/A1, owner-confirmed): the derived
* name is the wire identity of every document, citation, KB-tree node,
* saved chat, shared page and source/path locator. The name typed here is
* an ALIAS the prompt layers read (tasks 04/05) — never a rename — so
* nothing in this block writes, resolves or joins on it: it is looked up
* (encodeURIComponent in the path) exactly as the API's PUT/DELETE take it.
*
* THE SAVE CONTRACT (§7.4 never-stale, the phase-168 shape scoped PER ROW):
* an edit marks the row (.is-dirty) + "unsaved changes" + enables that row's
* Save; Save/Reset set the row's own `saveInFlight` (a second click while a
* request is out is IGNORED, and the in-flight label owns the status line),
* the row settles on "saved" or the one-line error note — never a stuck
* "Saving…" on ANY outcome (the finally always drops the flag). The PUT /
* DELETE RESPONSE is the new truth (no second GET — task 03): the stored
* label, the fields, the state tag and the summary all re-paint from the
* body the server just wrote, so the row can never show something the
* server does not have. Collapsing the editor DISCARDS the draft (no
* request) and refills from the stored label for the same reason. */
/* The caps of app/rag/source_labels (D1/D5), mirrored client-side so a
long tag never costs a round trip: the input's maxlength enforces the
32/120/200 char caps in the markup, this pair owns the chip count. */
const LABELS_ENDPOINT = "/api/source-labels";
const LABEL_TAG_MAX_CHARS = 32;
const LABEL_TAGS_MAX_COUNT = 10;
/* The save-state line's copy (role=status, one line per row). */
const LABEL_STATUS_SAVED = "saved";
const LABEL_STATUS_DIRTY = "unsaved changes";
const LABEL_STATUS_SAVING = "Saving…";
const LABEL_STATUS_RESETTING = "Resetting…";
/* The 404 arm NAMED (task 03): the source is gone from the catalogue
(removed elsewhere, or its last document pruned), so a retry cannot
help — the owner needs a reload, not a retype. */
const LABEL_NOTE_GONE = "That source no longer exists — reload the page.";
const LABEL_FAIL_SAVE = "Could not save the name — try again.";
const LABEL_FAIL_RESET = "Could not reset the name — try again.";
/* Per-clone id sequence: the template ships ONE id per field, and a
literal clone N times over would duplicate it (WCAG 1.3.1 — every
<label for> would aim at the first match). makeLabelRow rewrites the
input ids, the matching <label for>s and the editor id (the toggle's
aria-controls target) with this counter. */
let labelRowSeq = 0;
function storedFromEntry(entry) {
/* One label row in its SERVER shape → the row's stored state. Nulls /
missing keys fold to "" / [] (the API's "no row" shape is identical
to a cleared label — D1), so a row with nothing stored renders the
empty form and no Reset. */
const e = entry || {};
return {
display_name: typeof e.display_name === "string" ? e.display_name : "",
label: typeof e.label === "string" ? e.label : "",
tags: Array.isArray(e.tags) ? e.tags.map((tag) => String(tag)) : [],
};
}
function hasStoredLabel(stored) {
return Boolean(stored.display_name || stored.label || stored.tags.length);
}
/* The collapsed row's read-out of the stored label — name · label · the
tag list. "" renders nothing (the row shows the toggle instead). */
function labelSummaryText(stored) {
const parts = [];
if (stored.display_name) parts.push(stored.display_name);
if (stored.label) parts.push(stored.label);
if (stored.tags.length) parts.push(stored.tags.join(", "));
return parts.join(" · ");
}
/* The token input's chip list, re-painted from the row's draft array.
The chips are the phase-89 tag idiom (.source-label-tag joins the
.git-source-ignore-count / -hidden-count / -branch-count rule — TEXT +
background, never color alone), so NO new tag-input CSS is needed
beyond the list's own layout. At the cap the input disables (the tenth
chip is the last one the API accepts). */
function paintLabelTags(view) {
view.tagsList.replaceChildren(
...view.tags.map((tag) => {
const li = document.createElement("li");
li.className = "source-label-tag";
li.textContent = tag; // textContent only (the XSS contract)
return li;
}),
);
const full = view.tags.length >= LABEL_TAGS_MAX_COUNT;
view.tagsInput.disabled = full;
view.tagsInput.placeholder = full
? `${LABEL_TAGS_MAX_COUNT} tags at most`
: "gitea";
}
/* Type + Enter (or a comma) adds a chip. The rules are the API's
normalize_tags, applied client-side: trim, drop the empties, keep the
FIRST case of a duplicate, cap the count — a duplicate chip would
otherwise show the owner two chips the server de-duplicates to one. */
function addLabelTags(view, raw) {
for (const piece of String(raw).split(",")) {
const tag = piece.trim().slice(0, LABEL_TAG_MAX_CHARS);
if (!tag) continue;
if (view.tags.some((t) => t.toLowerCase() === tag.toLowerCase())) continue;
if (view.tags.length >= LABEL_TAGS_MAX_COUNT) break;
view.tags.push(tag);
}
view.tagsInput.value = "";
paintLabelTags(view);
updateLabelRowUI(view);
}
function readLabelForm(view) {
return {
display_name: view.nameInput.value.trim(),
label: view.labelInput.value.trim(),
tags: view.tags.slice(),
};
}
function fillLabelForm(view) {
view.nameInput.value = view.stored.display_name;
view.labelInput.value = view.stored.label;
view.tagsInput.value = "";
view.tags = view.stored.tags.slice();
paintLabelTags(view);
}
function labelFormDirty(view) {
const form = readLabelForm(view);
return (
form.display_name !== view.stored.display_name ||
form.label !== view.stored.label ||
form.tags.join("\n") !== view.stored.tags.join("\n")
);
}
function setLabelStatus(view, text, kind) {
view.statusEl.textContent = text;
view.statusEl.classList.toggle("is-error", kind === "error");
view.statusEl.classList.toggle("is-hint", kind === "hint");
}
/* The row's stored-state paint: the "named" tag, the summary line, the
Reset button (a row with nothing stored has nothing to reset — the
phase-168 per-row reset shape) and the toggle's label + aria-label
(the ONLY place the source name rides on the controls — setAttribute,
never innerHTML, the phase-89/105 idiom). */
function paintLabelRowState(view) {
const labelled = hasStoredLabel(view.stored);
if (view.stateTag) view.stateTag.hidden = !labelled;
if (view.summaryEl) {
const text = labelSummaryText(view.stored);
view.summaryEl.textContent = text;
view.summaryEl.hidden = text === "";
}
view.resetBtn.hidden = !labelled;
view.toggleBtn.textContent = labelled ? "Edit name" : "Name this source";
view.toggleBtn.setAttribute(
"aria-label",
`${labelled ? "Edit" : "Set"} the name, label and tags for source: ${view.source}`,
);
view.saveBtn.setAttribute(
"aria-label",
`Save the name, label and tags for source: ${view.source}`,
);
view.resetBtn.setAttribute(
"aria-label",
`Reset the name, label and tags of source: ${view.source}`,
);
}
/* Drop a failure note the moment the draft moves again: the note
explains the submission the server refused, and that submission is
gone as soon as the owner types. Called by the edit handlers ONLY —
never by updateLabelRowUI, which is exactly what a finishing request
calls (and would erase the note it just wrote). */
function clearLabelError(view) {
if (view.statusEl.classList.contains("is-error")) setLabelStatus(view, "", null);
}
/* The per-row dirty paint (the phase-168 .is-dirty shape, scoped to ONE
row instead of the panel). While a request is out the in-flight label
owns the status line — a keystroke mid-save must not overwrite it. */
function updateLabelRowUI(view) {
const dirty = labelFormDirty(view);
view.tr.classList.toggle("is-dirty", dirty);
if (view.saveInFlight) return;
view.saveBtn.disabled = !dirty;
/* A FAILED save's note survives this paint (§7.4): the dirty hint and
the note are the same one line, and the hint is the weaker claim —
silently replacing "That source no longer exists — reload the
page." with "unsaved changes" hides the one error the owner cannot
fix by retrying. The note clears on the next edit (clearLabelError)
or on a collapse, never by re-deriving the dirty state. */
if (view.statusEl.classList.contains("is-error")) return;
if (dirty) setLabelStatus(view, LABEL_STATUS_DIRTY, "hint");
else if (view.statusEl.classList.contains("is-hint")) {
setLabelStatus(view, "", null);
}
}
/* Expand / collapse. A collapse discards the draft (no request) and
refills from the stored label, so a closed row never claims an edit the
server never got; a save in flight freezes the row. */
function setLabelEditorOpen(view, open) {
if (view.saveInFlight) return;
view.editor.hidden = !open;
view.toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (!open) {
fillLabelForm(view);
setLabelStatus(view, "", null);
}
updateLabelRowUI(view);
}
/* The one-line error note. The 404 arm names itself (a retry cannot fix a
source that left the catalogue); every other non-2xx carries the server
detail (apiDetail, 422 shape-aware — the cap that was crossed names
itself there). */
async function labelFailureNote(r, fallback) {
if (r.status === 404) return LABEL_NOTE_GONE;
return await apiDetail(r, fallback);
}
/* PUT the whole form (the three keys always ride — an empty Name is the
deliberate CLEAR leg, the API folds "" to NULL; the tags array is the
chip list). 200 → the response body IS the row's new stored label. */
async function saveLabelRow(view) {
if (view.saveInFlight) return; // §7.4 — one request per row at a time
const form = readLabelForm(view);
view.saveInFlight = true;
view.saveBtn.disabled = true;
view.resetBtn.disabled = true;
setLabelStatus(view, LABEL_STATUS_SAVING, null);
try {
const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (r.ok) {
/* The response is the new truth — NO second GET (task 03). A body
that cannot be parsed degrades to what was just sent (never to
"unknown": the write succeeded either way). */
let saved = null;
try {
saved = await r.json();
} catch {
saved = null;
}
view.stored = storedFromEntry(
saved || {
display_name: form.display_name || null,
label: form.label || null,
tags: form.tags,
},
);
fillLabelForm(view);
paintLabelRowState(view);
hideLabelsError(); // a healed save heals the catalogue line too
setLabelStatus(view, LABEL_STATUS_SAVED, null);
return;
}
setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_SAVE), "error");
} catch {
setLabelStatus(view, `${LABEL_FAIL_SAVE} Is the app reachable?`, "error");
} finally {
// Never stale on ANY outcome (§7.4): the flag drops, the buttons
// come back, and the dirty paint re-decides Save.
view.saveInFlight = false;
view.resetBtn.disabled = false;
updateLabelRowUI(view);
}
}
/* DELETE the row — the fields clear, the stored label nulls, and the
row settles on the same "saved" line (the row now matches the server,
which is all the line has ever claimed). */
async function resetLabelRow(view) {
if (view.saveInFlight) return; // the SAME per-row guard covers Reset
view.saveInFlight = true;
view.saveBtn.disabled = true;
view.resetBtn.disabled = true;
setLabelStatus(view, LABEL_STATUS_RESETTING, null);
try {
const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
method: "DELETE",
});
if (r.ok) {
// The 200 body is the entry's cleared state (task 02) — the row
// re-reads it instead of assuming, and the fields clear with it.
let cleared = null;
try {
cleared = await r.json();
} catch {
cleared = null;
}
view.stored = storedFromEntry(cleared || {});
fillLabelForm(view);
paintLabelRowState(view);
hideLabelsError();
setLabelStatus(view, LABEL_STATUS_SAVED, null);
return;
}
setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_RESET), "error");
} catch {
setLabelStatus(view, `${LABEL_FAIL_RESET} Is the app reachable?`, "error");
} finally {
view.saveInFlight = false;
view.resetBtn.disabled = false;
updateLabelRowUI(view);
}
}
/* Clone the page's ONE row template for one catalogue entry. The
template is static markup (no data): every value lands through
textContent / .value, and the row's own object (`view`) carries its
state — stored label, chip draft, saveInFlight — closed over by its
listeners. */
function makeLabelRow(entry) {
const proto =
labelRowTemplate && labelRowTemplate.content
? labelRowTemplate.content.querySelector("tr")
: null;
if (!proto) return null; // defensive — the template ships with the page
const tr = proto.cloneNode(true);
labelRowSeq += 1;
const seq = labelRowSeq;
const pick = (sel) => tr.querySelector(sel);
const nameInput = pick(".source-label-name-input");
const labelInput = pick(".source-label-label-input");
const tagsInput = pick(".source-label-tags-input");
const tagsList = pick(".source-label-tags");
const editor = pick(".source-label-editor");
const toggleBtn = pick(".source-label-toggle");
const saveBtn = pick(".source-label-save");
const resetBtn = pick(".source-label-reset");
const statusEl = pick(".source-label-status");
if (!nameInput || !labelInput || !tagsInput || !tagsList || !editor ||
!toggleBtn || !saveBtn || !resetBtn || !statusEl) {
return null; // a template without the fields is a build error, not a row
}
/* Rewrite the template's static ids (and the <label for>s that point at
them) so every row keeps a unique id and a real label pair; the
editor id is the toggle's aria-controls target. */
for (const [input, base] of [
[nameInput, "source-label-name"],
[labelInput, "source-label-label"],
[tagsInput, "source-label-tags"],
]) {
input.id = `${base}-${seq}`;
const lbl = tr.querySelector(`label[for="${base}"]`);
if (lbl) lbl.setAttribute("for", input.id);
}
editor.id = `source-label-editor-${seq}`;
toggleBtn.setAttribute("aria-controls", editor.id);
const codeEl = pick(".source-label-source");
const source = String((entry && entry.source) || "");
if (codeEl) codeEl.textContent = source; // the identity: read-only text
const cell = pick(".source-label-id-cell");
if (cell) cell.title = source; // long names hover in full (house idiom)
const view = {
source,
tr,
stored: storedFromEntry(entry),
tags: [],
saveInFlight: false,
nameInput,
labelInput,
tagsInput,
tagsList,
editor,
toggleBtn,
saveBtn,
resetBtn,
statusEl,
stateTag: pick(".source-label-state"),
summaryEl: pick(".source-label-summary"),
};
fillLabelForm(view);
paintLabelRowState(view);
updateLabelRowUI(view);
for (const input of [nameInput, labelInput]) {
input.addEventListener("input", () => {
clearLabelError(view);
updateLabelRowUI(view);
});
}
tagsInput.addEventListener("keydown", (e) => {
clearLabelError(view);
// Enter or a comma commits the token; the comma never "types".
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addLabelTags(view, tagsInput.value);
return;
}
// Backspace on an empty input drops the LAST chip (the token rule).
if (e.key === "Backspace" && tagsInput.value === "" && view.tags.length) {
e.preventDefault();
view.tags.pop();
paintLabelTags(view);
updateLabelRowUI(view);
}
});
// Leaving the field commits a half-typed token (nothing is lost).
tagsInput.addEventListener("blur", () => {
if (tagsInput.value.trim()) addLabelTags(view, tagsInput.value);
});
toggleBtn.addEventListener("click", () => setLabelEditorOpen(view, editor.hidden));
saveBtn.addEventListener("click", () => saveLabelRow(view));
resetBtn.addEventListener("click", () => resetLabelRow(view));
return tr;
}
/* GET the label catalogue (ONE fetch — the union the server assembles).
A failure is this list's own role=alert line: the sources table above
keeps working (the two lists are independent), and the table + empty
state hide while the error is up so the note is the only claim about
the rows (the #git-sources-load-error discipline). */
async function loadLabels() {
let r;
try {
r = await fetch(LABELS_ENDPOINT);
} catch {
showLabelsError("Could not reach the server — the source names are not shown.");
return;
}
if (!r.ok) {
showLabelsError(
await apiDetail(r, `The server could not list the source names (${r.status}).`),
);
return;
}
let data;
try {
data = await r.json();
} catch {
showLabelsError("The server sent an unreadable label list — try again.");
return;
}
hideLabelsError();
renderLabelRows(Array.isArray(data.labels) ? data.labels : []);
}
function renderLabelRows(entries) {
if (!labelsTbody) return;
labelsTbody.replaceChildren(); // the list is REPLACED, never appended
for (const entry of entries) {
const tr = makeLabelRow(entry);
if (tr) labelsTbody.appendChild(tr);
}
const hasRows = labelsTbody.children.length > 0;
if (labelsWrap) labelsWrap.hidden = !hasRows;
if (labelsEmptyEl) labelsEmptyEl.hidden = hasRows;
}
function showLabelsError(message) {
if (labelsErrorEl) {
labelsErrorEl.textContent = message;
labelsErrorEl.hidden = false;
}
// The list state is unknown — hide the table AND the empty state.
if (labelsWrap) labelsWrap.hidden = true;
if (labelsEmptyEl) labelsEmptyEl.hidden = true;
}
function hideLabelsError() {
if (labelsErrorEl) {
labelsErrorEl.textContent = "";
labelsErrorEl.hidden = true;
}
}
/* ---------- add (POST /api/git-sources) — the git form ----------
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
* the request is out the button disables + relabels "Adding…" and
@@ -1065,6 +1596,7 @@ export async function mount(root) {
if (branchInput) branchInput.value = ""; // the branch is stored (NULL when blank)
announce(opts.addedMessage);
await loadSources(); // the new row lands in the table
await loadLabels(); // phase 191: and it is nameable straight away
focusNewRow(createdId); // a11y: land the caret on the new row
return;
}
@@ -1333,6 +1865,7 @@ export async function mount(root) {
uploadFileInput.value = "";
restoreUploadButton();
loadSources(); // the row lands / refreshes
loadLabels(); // phase 191: the uploaded source joins the name list
return;
}
if (status.state === "failed") {
@@ -1524,6 +2057,13 @@ export async function mount(root) {
the whoami gate passed: anonymous shows the gate and must never
fetch /api/git-sources (the Sources-page gate pattern). */
root.addEventListener("bor:view-refresh", () => loadSources());
/* Phase 191 (task 03): the label catalogue rides the same re-show
refresh. A SECOND listener (rather than a call inside loadSources)
keeps the phase-77 listener above verbatim AND keeps the two lists
independent — the ignore-paths / hidden-folders saves reload the
source table without ever wiping a half-typed name draft. */
root.addEventListener("bor:view-refresh", () => loadLabels());
await loadLabels(); // phase 191: the names list (admin branch only)
await loadSources();
// Phase 64 (task 05): re-attach a running run (a reload mid-run
// resumes the bare Processing state) or re-render a terminal