phase: 129_sync_chunk_progress
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:
+78
-33
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user