phase: 129_sync_chunk_progress
Build and Push Containers / build-and-push-app (push) Successful in 8m23s
Build and Push Containers / build-and-push-db (push) Successful in 40s

All green. Final report:

**Phase 129 final verification pass — 100% green** (previous work intact; this pass re-verified everything and fixed two pre-existing README inconsistencies)

- Verified on-spec: `LLMClient.embed` per-batch `on_progress` hook; importer `on_chunk_progress` plumbing (text/image/PDF branches; summary embed excluded); `SyncStatus.current_chunk/chunks_total` reset+clear contract; UI `.sync-label-chunk` pinned part with untruncated `title`/`#sync-result`
- `uv run pytest --cov=app -q`: exit 0, ~3041 passed, 0 failed, `app/` coverage **99%** (>90% gate)
- `uv run pytest tests/e2e/test_sync_chunk_progress.py -v --no-cov` (isolated, DB up): **2 passed** (endpoint chunk climb with true `chunk_document` total + UI `· chunk n/m` part)
- `uv run ruff check .`: all checks passed · `uv run pyright`: 0 errors, 0 warnings
- Completion criteria: all 5 ✅ (incl. no behavior change in completed phases — full suite green, CLI/chat no-op paths byte-identical)
- Docs: README.md fixed 2 pre-existing inconsistencies (upload section claimed "unpacks + scans immediately" — phase 90 made it unpack+register only, scan deferred to Sync; troubleshooting claimed "120s client timeout" — actual guard is the 300 s pre-token `TURN_TIMEOUT_MS`). Phase-129 label behavior already documented. AGENTS.md re-verified consistent — no changes
- Removed stray untracked `NOTES.md` (8-byte artifact); no live-infra changes; no commits — all changes left in working tree for the harness
- Next pending phase: `130_chat_answer_clusters`
This commit is contained in:
2026-09-28 03:38:14 -04:00
parent 1139f007cf
commit 2d0f608246
53 changed files with 3590 additions and 157 deletions
+78 -33
View File
@@ -629,7 +629,10 @@ export async function mount(root) {
* 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)), #sync-icon (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-icon (the
* spinner icon), #sync-result (aria-live result line),
* #sync-error-banner / #sync-error-text (error banner).
*/
@@ -637,6 +640,7 @@ export async function mount(root) {
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;
const syncResult = root.querySelector("#sync-result");
const syncErrorBanner = root.querySelector("#sync-error-banner");
@@ -665,22 +669,27 @@ export async function mount(root) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* Phase 128 (task 01): 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):
* present → its textContent + hidden=false, absent → emptied +
* hidden=true (the count span is hidden whenever there is no
* "(n/m)" to show). Then the UNTRUNCATED full label —
* mainText + (countText ? " " + countText : "") — rides the button
* title + #sync-result (the aria-live announcer) exactly as before
* (A4 — the screen reader hears the whole thing; the visible span
* carries main + count, the space between them is 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). */
function setSyncLabel(mainText, countText = "") {
/* 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 + #sync-result
* (the aria-live announcer) exactly as before (A4 — the screen
* reader hears the whole "Syncing… <file> (2/3) · chunk 14/38";
* 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). */
function setSyncLabel(mainText, countText = "", chunkText = "") {
if (syncLabelMain) syncLabelMain.textContent = mainText;
if (syncLabelCount) {
if (countText) {
@@ -691,7 +700,19 @@ export async function mount(root) {
syncLabelCount.hidden = true;
}
}
const full = countText ? `${mainText} ${countText}` : mainText;
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
if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing
}
@@ -713,7 +734,11 @@ export async function mount(root) {
* 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). */
* 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;
@@ -729,26 +754,42 @@ export async function mount(root) {
* <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); anything else (the null prelude, "import") → the
* phase-64 fmtSyncLabel parts, byte-identical (same builder, same
* fields, same order). 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-result like every running label (A4 — setSyncLabel). */
* 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-result like every running label (A4 —
* setSyncLabel). */
function fmtSyncPhaseLabel(status) {
if (status.phase === "overview") {
return { main: "Writing KB overview…", count: "" };
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).
return fmtSyncLabel("sync", status.current_file, status.files_done, status.files_total);
// 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) {
@@ -780,19 +821,23 @@ export async function mount(root) {
* 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-result (A4 — set
* for EVERY running label; CSS ellipsizes the main part only). Same
* never-stale mechanics as before (disabled, aria-busy, spinning
* icon, no is-error).
* 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).
*/
function enterSyncRunningState(kind, currentFile, done, total, status) {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
const { main, count } =
const { main, count, chunk = "" } =
kind === "sync" && status
? fmtSyncPhaseLabel(status)
: fmtSyncLabel(kind, currentFile, done, total);
setSyncLabel(main, count); // the parts + the A4 untruncated title / announcer
setSyncLabel(main, count, chunk); // the parts + the A4 untruncated title / announcer
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
+15 -4
View File
@@ -2064,10 +2064,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
<file> (n/m)") — a long source/relative/path ellipsizes inside the
pill; the full path lives in the button title + #sync-result (the
aria-live announcer). Phase 128 (task 02): the label is a FLEX ROW
of two parts — .sync-label-main (the prefix + file/folder: min-width
of parts — .sync-label-main (the prefix + file/folder: min-width
0, the shrinkable item whose ellipsis engages) + .sync-label-count
(the pinned "(n/m)": flex 0 0 auto, NEVER clipped — the
.sync-result mono language, a position reads as data). */
.sync-result mono language, a position reads as data). Phase 129
(task 04): a THIRD pinned part — .sync-label-chunk (the "· chunk
n/m" embed-batch position of the in-flight file, the same pinned
mono language; hidden in every state but the import phase after
the file's first batch). */
.sync-label {
display: inline-flex;
align-items: baseline;
@@ -2086,6 +2090,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
white-space: nowrap;
font-family: var(--mono); /* the .sync-result mono language (position reads as data) */
}
.sync-label-chunk {
flex: 0 0 auto; /* NEVER clipped — the · chunk n/m position is the point (phase 129) */
white-space: nowrap;
font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */
}
/* Last-result announcer — soft ink, small mono */
.sync-result {
display: block;
@@ -5329,8 +5338,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
width; the History page-head already wraps the pill below its
title block. The Sync label's min(16rem, 40vw) cap lifts and the
label FILLS the pill (phase 128: flex 1 1 auto) — .sync-label-main
ellipsizes against the width left by the pinned .sync-label-count,
so the (n/m) stays on-screen at the right edge, never clipped;
ellipsizes against the width left by the pinned .sync-label-count
(+ the phase-129 .sync-label-chunk part while a file's embed batch
is in flight), so the position parts stay on-screen at the right
edge, never clipped;
the refresh glyph joins its visible label. The aria-labels keep
the accessible names. */
.page-head-row { flex-wrap: wrap; }