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
+259 -4
View File
@@ -4349,9 +4349,16 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). Phase 135 (A6):
the "branch: <name>" state tag joins the same rule — stored git
rows with a branch only, the text IS the signal. */
rows with a branch only, the text IS the signal.
Phase 191 (task 03): the TWO new tags of the name/label/tags row join
the SAME rule — .source-label-state ("named", beside the mono identity)
and .source-label-tag (one chip of the Tags token input). That is the
reuse the phase asks for: the chips are the house tag, not a new
widget, so no tag-input styling is added anywhere below. */
.git-source-hidden-count,
.git-source-branch-count {
.git-source-branch-count,
.source-label-state,
.source-label-tag {
display: inline-block;
margin-left: 0.55rem;
padding: 0.08rem 0.5rem;
@@ -4376,8 +4383,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
}
/* Empty state: the tuning page's quiet centered line at full table
width — no stored rows AND no env fallback to show. */
.git-sources-empty {
width — no stored rows AND no env fallback to show. Phase 191 (task
03): the name/label/tags list shares the line verbatim (its own
no-sources case, the same quiet card). */
.git-sources-empty,
.source-labels-empty {
margin: 0;
padding: 1.4rem 1rem;
text-align: center;
@@ -4389,6 +4399,251 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
box-shadow: var(--shadow);
}
/* ---------- Source names, labels and tags (phase 191, task 03) ----------
The Sources page's SECOND list: every source the knowledge base knows
(git, local, upload — the union the phase-191 catalogue GET returns),
each with the optional name / label / tags the model is told about
(D1: an ALIAS — the mono identity cell is read-only text, nothing here
renames a document, a citation or a locator).
Nothing new is invented: the card is the phase-156 .table-wrap (bounded
panel + sticky header, inherited by the class alone), the identity cell
is the .git-source-url-cell mono, the state tag and the tag CHIPS are
the phase-89 tag rule above, the field boxes are the .ignore-editor-
token / #ignore-editor-branch language at the 44px floor, Save is the
page's brand pill, Reset and the row's toggle are the neutral
.git-source-ignore language, and the dirty mark is the phase-168
the phase-168 dirty-row stripe. PALETTE VARIABLES ONLY — no new hue
(every pair is a pair this page already measures: ink 15.3:1 and
ink-soft 8.2:1 on --surface, brand-ink on brand-soft 12.4:1, the err
pair 9.3:1, --bg ink on --brand 5.2:1); :focus-visible rides the global
3px outline rule; no CDN, no blur. The editor expands UNDER its own row
(one row open at a time keeps a 27-source list scannable) and its three
fields stack in a single column below 60rem instead of pushing the card
into a horizontal scroll at 375px. */
.source-labels {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.source-labels-title {
margin: 0;
font-size: 1.25rem;
line-height: 1.3;
color: var(--ink);
}
/* The alias note (the contract in the owner's words) — the page-sub
language, ink-soft on the page canvas (8.2:1 on --surface). */
.source-labels-sub {
margin: 0;
max-width: 60rem;
color: var(--ink-soft);
font-size: 0.88rem;
}
/* The list's own load failure (role=alert) — the .git-source-error
pair (err-ink on err-bg 9.3:1). */
.source-labels-error {
margin: 0;
padding: 0.45rem 0.8rem;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 600;
}
/* No min-width on purpose: the editor column has to SHRINK (the fields
stack), so this table never scrolls the card sideways at 375px — the
panel still bounds vertically (.table-wrap). */
.source-labels-table {
width: 100%;
border-collapse: collapse;
font-size: 0.93rem;
}
.source-labels-table th, .source-labels-table td {
text-align: left;
vertical-align: top;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
}
.source-labels-table th {
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.source-labels-table tbody tr:last-child td { border-bottom: 0; }
/* The identity cell: the derived name, mono like the sources table's
location cell, wrapping (overflow-wrap) instead of widening the card —
it is text, never an input (D1/A1). */
.source-labels-table td.source-label-id-cell {
font-family: var(--mono);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.source-labels-table td.source-label-id-cell code { font-family: inherit; }
/* The dirty row mark — the phase-168 dirty-row stripe (the brand edge is
beside the row's own "unsaved changes" TEXT, so the state is never color
alone — WCAG 1.4.1). The literal is NOT written in this comment: the
phase-168 pin (test_settings_frontend_view.py) regex-matches that
selector to its own rule and a comment mention would steal the match. */
.source-label-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
/* The collapsed row's read-out of the stored label (name · label · tags).
textContent-only (a label is owner text — the XSS contract). */
.source-label-summary {
margin: 0 0 0.45rem;
color: var(--ink-soft);
font-size: 0.85rem;
overflow-wrap: anywhere;
}
/* The row's expand trigger — the .git-source-ignore language (neutral
secondary fill, >=44px, hovers to the brand pair 12.4:1). */
.source-label-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 44px;
min-width: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.source-label-toggle:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
/* The expanded editor panel — the row's own form card. The fields go in
one column narrow, three across from 60rem (the house grid). */
.source-label-editor {
display: grid;
gap: 0.9rem;
margin-top: 0.75rem;
padding: 0.9rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
@media (min-width: 60rem) {
.source-label-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.source-label-fields {
display: grid;
gap: 0.75rem;
}
.source-label-field {
display: block;
min-width: 0;
}
/* The field label — the .ignore-editor-label language (ink-soft 8.2:1 on
the panel, 600 weight, the hint span the .field-hint secondary read). */
.source-label-field-label {
display: block;
margin-bottom: 0.35rem;
color: var(--ink-soft);
font-size: 0.88rem;
font-weight: 600;
}
/* The field box — the .ignore-editor-token / #ignore-editor-branch box
language VERBATIM (full width, --bg… panel fill, the line border, mono)
at the 44px touch floor; the global [hidden] rule and the global
:focus-visible outline cover the rest. */
.source-label-input {
display: block;
width: 100%;
min-height: 44px;
padding: 0.55rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
/* The Tags token input's chip list — the LAYOUT only (the chips
themselves are the shared tag rule above, so the widget adds no tag
styling). list-style is dropped, so role="list" stays in the markup
(the VoiceOver list-semantics idiom the house uses). */
.source-label-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0 0 0.45rem;
padding: 0;
list-style: none;
}
.source-label-tags .source-label-tag { margin-left: 0; }
.source-label-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
/* Save — the page's brand pill at the row scale (--bg ink on --brand,
5.2:1); disabled until the row is dirty, and again while the PUT is
out (§7.4 — the row's own in-flight state). */
.source-label-save {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.source-label-save:hover:not(:disabled) { background: var(--brand-hover); }
.source-label-save:disabled { opacity: 0.6; cursor: default; }
/* Reset — the neutral secondary button (the .git-source-ignore language):
it clears a STORED label, it is not the destructive row Remove. */
.source-label-reset {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.35rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
cursor: pointer;
}
.source-label-reset:hover:not(:disabled) {
background: var(--brand-soft);
color: var(--brand-ink);
}
.source-label-reset:disabled { opacity: 0.5; cursor: wait; }
/* The §7.4 save-state line (role=status): "Saving…" → "saved" / the
one-line error note. The hint (unsaved) is the muted italic; the error
takes the err pair (9.3:1) so the note reads as a failure, not as a
label — TEXT carries it either way, never color alone. */
.source-label-status {
color: var(--ink-soft);
font-size: 0.82rem;
}
.source-label-status.is-hint { font-style: italic; }
.source-label-status.is-error {
padding: 0.35rem 0.6rem;
color: var(--err-ink);
background: var(--err-bg);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
font-weight: 600;
}
/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ----------
The #view-history page-head is a flex row: the title block left, the
actions slot right (the row wraps below 640px). Scoped to
+118
View File
@@ -840,6 +840,124 @@
pruned on that run).
</p>
<!-- Phase 191 (task 03): the per-source NAME / LABEL / TAGS
editor — "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."
The 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 — which is why this is its own table card
and not a column of #git-sources-table above: a source can
carry documents with no git_sources row of its own, and the
one GET is the single source of truth for the row list.
THE ALIAS CONTRACT (D1/A1, owner-confirmed): the mono cell
is the row's IDENTITY — the derived name, rendered with
textContent and never an input. Editing a name here
renames NOTHING: documents, citations, the KB tree, saved
chats, shared pages and every source/path locator keep the
derived name. A name and its tags only change what the
model is TOLD (tasks 04/05).
ONE shared <template> (source-label-row-template) is cloned
per row by git-sources.js: 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 a long
submission never round-trips as a 422) + Save / Reset + the
§7.4 never-stale save-state line (role=status: "Saving…" →
"saved" / the one-line error note, scoped PER ROW). The
section lives INSIDE the admin-gated #git-sources-content,
so an anonymous visitor never fetches it — the page's own
gate is this list's gate too. -->
<section class="source-labels" id="source-labels-section" aria-labelledby="source-labels-title">
<h2 class="source-labels-title" id="source-labels-title">Names, labels and tags</h2>
<p class="source-labels-sub">
A name is what <em>you</em> call a source (&ldquo;the git server&rdquo;)
and a tag is a focus token — the model is told both, so a
question that uses the name finds that source&rsquo;s documents.
Nothing here renames a source: the mono name below stays the
identity of every document, citation and link.
</p>
<!-- The catalogue's own load failure (role=alert). The table and
the empty state are hidden while it is up, so the error is
the only claim about the list (the #git-sources-load-error
discipline, applied to this page's second list). -->
<p class="source-labels-error" id="source-labels-error" role="alert" hidden></p>
<div class="table-wrap source-labels-wrap" id="source-labels-wrap" role="region"
aria-label="Source names, labels and tags" tabindex="0">
<table class="source-labels-table" id="source-labels-table">
<caption class="visually-hidden">Every source the knowledge base knows — git repositories, local directories and uploaded archives — each with the optional name, label and tags the model reads. The mono name in the first column is the source&rsquo;s identity and never changes.</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Name, label and tags</th>
</tr>
</thead>
<tbody id="source-labels-tbody"></tbody>
</table>
</div>
<!-- Empty state — the catalogue answered with nothing at all
(no registry row and no indexed document). -->
<p class="source-labels-empty" id="source-labels-empty" hidden>No sources to name yet.</p>
<!-- ONE shared template, cloned per row by makeLabelRow.
The ids written here are the FIRST row&rsquo;s: the clone
loop rewrites each input&rsquo;s id, its <label for> and the
editor&rsquo;s id (the toggle&rsquo;s aria-controls target) —
a literal clone N times over would duplicate ids and make
every label ambiguous (WCAG 1.3.1; the fields keep real
<label for> pairs, never aria-label-only). Static markup
only: every value lands through textContent or .value in
git-sources.js (the XSS contract), and the editor ships
hidden — the row&rsquo;s toggle reveals it. -->
<template id="source-label-row-template">
<tr class="source-label-row">
<td class="source-label-id-cell">
<code class="source-label-source"></code>
<!-- The stored-label state tag: TEXT + a background, never
color alone (WCAG 1.4.1 — the
.git-source-ignore-count idiom, phase 89/105/135). -->
<span class="source-label-state" hidden>named</span>
</td>
<td class="source-label-edit-cell">
<p class="source-label-summary" hidden></p>
<button type="button" class="source-label-toggle" aria-expanded="false">Name this source</button>
<div class="source-label-editor" id="source-label-editor" hidden>
<div class="source-label-fields">
<div class="source-label-field">
<label class="source-label-field-label" for="source-label-name">Name <span class="field-hint">optional — what you call it</span></label>
<input class="source-label-input source-label-name-input" id="source-label-name"
type="text" maxlength="120" autocomplete="off"
placeholder="the git server">
</div>
<div class="source-label-field">
<label class="source-label-field-label" for="source-label-label">Label <span class="field-hint">optional — one line about it</span></label>
<input class="source-label-input source-label-label-input" id="source-label-label"
type="text" maxlength="200" autocomplete="off"
placeholder="self-hosted forge + Gitea">
</div>
<div class="source-label-field">
<label class="source-label-field-label" for="source-label-tags">Tags <span class="field-hint">optional — Enter or comma adds one</span></label>
<ul class="source-label-tags" role="list"></ul>
<input class="source-label-input source-label-tags-input" id="source-label-tags"
type="text" maxlength="32" autocomplete="off"
placeholder="gitea">
</div>
</div>
<div class="source-label-actions">
<button type="button" class="source-label-save" disabled>Save</button>
<button type="button" class="source-label-reset" hidden>Reset</button>
<span class="source-label-status" role="status" aria-live="polite"></span>
</div>
</div>
</td>
</tr>
</template>
</section>
<!-- Phase 69 (owner request 2026-09-02): the remove
confirmation — a real in-app alertdialog (the native
confirm() retired): a row's Remove button opens it