Composer: recognised NLP chips flicker while typing elsewhere; chips must only react when their own span is edited #416

Closed
opened 2026-09-29 09:08:14 +00:00 by kayg · 6 comments
Owner

Bug (owner, 2026-09-29)

"As I keep typing in the composer, already decided NLP chips like the time keep disappearing and appearing even though that part of the text is not being changed. NLP chips should only react when those parts are edited."

Expected

A recognised span (time, date, duration, tag, place …) that the User did not edit keeps its chip and highlight stable, with no flicker, no remount and no re-animation. It does this while the User types anywhere else in the composer, before or after the span.

  • A chip changes only when an edit touches its own span, or when an edit makes the span part of a longer match. For example, typing "at 5" and then "pm" extends the same chip; it does not replace it.
  • A span the User has dismissed or corrected stays dismissed (corrections.ts).
  • Typing before a span shifts its offsets. The chip must keep its identity: use a stable key and do not rebuild the list.

Where to look

  • apps/web/src/lib/composer/{Composer.svelte,controller.svelte.ts,nlp.ts,runs.ts,corrections.ts}
  • packages/ui/src/components/composer/{HighlightOverlay.svelte,FieldPopover.svelte}
  • the Rust recogniser in crates/calternal-notes-core/src/composer.rs, if the parse round trip is async (stale responses arriving out of order are a likely cause, as is a keyed each on offsets).

Find the root cause first and write it down. Likely causes:

  1. {#each} is keyed by offset or index, so a shift remounts the chip.
  2. The parse is async per keystroke, and the list is cleared while the request is in flight.
  3. A debounced reparse shows an empty state in between.
    Fix it at the source: reconcile the new parse result with the old one by span identity.

Proof

  • A unit test: type characters before, inside and after a recognised time. Assert that the chip's DOM node is the same (identity) and that it never leaves the DOM.
  • A production-build e2e (apps/web/e2e/composer.mjs) that types a long sentence with a time chip early on. Sample the DOM on every animation frame and assert the chip is present in every frame.
  • A short screen capture or frame strip at 390 and 1440 px, light and dark.
    Gates per crate as in the preamble, plus bun run check and bun run test in apps/web.
## Bug (owner, 2026-09-29) "As I keep typing in the composer, already decided NLP chips like the time keep disappearing and appearing even though that part of the text is not being changed. NLP chips should only react when those parts are edited." ## Expected A recognised span (time, date, duration, tag, place …) that the User did not edit keeps its chip and highlight stable, with no flicker, no remount and no re-animation. It does this while the User types anywhere else in the composer, before or after the span. - A chip changes only when an edit touches its own span, or when an edit makes the span part of a longer match. For example, typing "at 5" and then "pm" extends the same chip; it does not replace it. - A span the User has dismissed or corrected stays dismissed (`corrections.ts`). - Typing before a span shifts its offsets. The chip must keep its identity: use a stable key and do not rebuild the list. ## Where to look - `apps/web/src/lib/composer/{Composer.svelte,controller.svelte.ts,nlp.ts,runs.ts,corrections.ts}` - `packages/ui/src/components/composer/{HighlightOverlay.svelte,FieldPopover.svelte}` - the Rust recogniser in `crates/calternal-notes-core/src/composer.rs`, if the parse round trip is async (stale responses arriving out of order are a likely cause, as is a keyed each on offsets). Find the root cause first and write it down. Likely causes: 1. `{#each}` is keyed by offset or index, so a shift remounts the chip. 2. The parse is async per keystroke, and the list is cleared while the request is in flight. 3. A debounced reparse shows an empty state in between. Fix it at the source: reconcile the new parse result with the old one by span identity. ## Proof - A unit test: type characters before, inside and after a recognised time. Assert that the chip's DOM node is the same (identity) and that it never leaves the DOM. - A production-build e2e (`apps/web/e2e/composer.mjs`) that types a long sentence with a time chip early on. Sample the DOM on every animation frame and assert the chip is present in every frame. - A short screen capture or frame strip at 390 and 1440 px, light and dark. Gates per crate as in the preamble, plus `bun run check` and `bun run test` in `apps/web`.
Author
Owner

Starting #416 on branch job/chips-416, based on dev at 99ee1fc9bbb0b3dd1f2d5b35e15529d1d0b3633c.

Initial finding: Composer.svelte gates liveParse on an exact whole-line match. On each edit, the prior parse becomes ineligible immediately while the next parse is debounced and asynchronous, so all NLP runs disappear from the chip and highlight DOM until the response arrives. Tag keys also include their start/end offsets, so inserting text before a tag changes its key. I am implementing span reconciliation and regression coverage for both behaviors.

Starting #416 on branch `job/chips-416`, based on `dev` at `99ee1fc9bbb0b3dd1f2d5b35e15529d1d0b3633c`. Initial finding: `Composer.svelte` gates `liveParse` on an exact whole-line match. On each edit, the prior parse becomes ineligible immediately while the next parse is debounced and asynchronous, so all NLP runs disappear from the chip and highlight DOM until the response arrives. Tag keys also include their start/end offsets, so inserting text before a tag changes its key. I am implementing span reconciliation and regression coverage for both behaviors.
Author
Owner

Confirmed the additional remount paths while tracing the component:

  • Composer.svelte defines liveParse only when parse.line === line1; runs was derived from liveParse, so one whole-line edit cleared every run while the 90 ms debounce and API request ran.
  • The time range and date chips were inside {#key} blocks keyed by parsed values, which forced Svelte to replace the node when a recognized value changed. Tag chip keys included start/end offsets, and HighlightOverlay.svelte keyed marks by segment index.

The fix carries client span identities through offset rebasing and fresh core results. Untouched chips and marks can stay mounted while a parse is pending; a parse failure drops only provisional runs that the edit touched. The new component test holds the parser response and edits before, inside, and after a time span while checking node identity.

Confirmed the additional remount paths while tracing the component: - `Composer.svelte` defines `liveParse` only when `parse.line === line1`; `runs` was derived from `liveParse`, so one whole-line edit cleared every run while the 90 ms debounce and API request ran. - The time range and date chips were inside `{#key}` blocks keyed by parsed values, which forced Svelte to replace the node when a recognized value changed. Tag chip keys included start/end offsets, and `HighlightOverlay.svelte` keyed marks by segment index. The fix carries client span identities through offset rebasing and fresh core results. Untouched chips and marks can stay mounted while a parse is pending; a parse failure drops only provisional runs that the edit touched. The new component test holds the parser response and edits before, inside, and after a time span while checking node identity.
Author
Owner

Production e2e finding: the existing time-range geometry assertion sampled during the depix reveal. On the failing run the start chip had transform: matrix(0.861353, 0, 0, 0.861353, 0, 0), height 26.24 px at y=395.75; the settled end chip was 32 px high at y=392.87. The 2.88 px offset was an animation-phase measurement, so I changed the probe to wait for chip animations before measuring and kept the same one-line assertion.

The same run reported two TypeError: Cannot read properties of null (reading 'origin') page errors during initial navigation. app-sidebar.svelte checked from and to but dereferenced their nullable url fields. I added URL guards so the initial navigation does not throw.

Production e2e finding: the existing time-range geometry assertion sampled during the `depix` reveal. On the failing run the start chip had `transform: matrix(0.861353, 0, 0, 0.861353, 0, 0)`, height 26.24 px at y=395.75; the settled end chip was 32 px high at y=392.87. The 2.88 px offset was an animation-phase measurement, so I changed the probe to wait for chip animations before measuring and kept the same one-line assertion. The same run reported two `TypeError: Cannot read properties of null (reading 'origin')` page errors during initial navigation. `app-sidebar.svelte` checked `from` and `to` but dereferenced their nullable `url` fields. I added URL guards so the initial navigation does not throw.
Author
Owner

A follow-up production e2e run passed the composer chip geometry after waiting for the depix animation, then stopped at a screenshot theme check. The main page inherited auto_scheme.mode = "system"; the check compared that to computed color-scheme: light, which is the resolved browser scheme. The composer e2e now sets its main page to explicit Paper/light before screenshots. This makes its 1440-paper-* captures deterministic and leaves the 390/1440 Paper and Tokyo Night runs explicit.

A follow-up production e2e run passed the composer chip geometry after waiting for the `depix` animation, then stopped at a screenshot theme check. The main page inherited `auto_scheme.mode = "system"`; the check compared that to computed `color-scheme: light`, which is the resolved browser scheme. The composer e2e now sets its main page to explicit Paper/light before screenshots. This makes its `1440-paper-*` captures deterministic and leaves the 390/1440 Paper and Tokyo Night runs explicit.
Author
Owner

Finished #416 on job/chips-416.

HEAD: 9f4259751f271061385a26b9cdd870f243864ea4

Root cause: liveParse exposed parsed runs only while their stored line exactly matched the current composer text, so the UI briefly had no chips during debounce and async parsing. Render keys also used changing parsed values, offsets, or segment indices, which remounted chips and highlight marks when text moved.

Built: client-side span reconciliation keeps IDs for unaffected spans as edits shift offsets; only touched spans are re-evaluated, and extended matches retain the span identity. Rendering keeps the previous parse visible while the next parse is pending. The authoritative exact-line parse still controls commit. The unit tests and production e2e check that the time chip and its mark retain the same DOM nodes, including after a correction removes the run.

Files: apps/web/src/lib/composer/{Composer.svelte,Composer.svelte.test.ts,nlp.ts,runs.ts,runs.test.ts}, packages/ui/src/components/composer/HighlightOverlay.svelte, apps/web/e2e/composer.mjs, and apps/web/src/lib/components/app-sidebar.svelte (guard nullable URLs during initial navigation; this removed two page errors found by the production e2e).

Decisions where DESIGN was silent: client span IDs are assigned locally and reconciled by run kind plus overlap after rebasing through the UTF-16 text edit. A touched provisional span is dropped if the current parser result no longer recognizes it; untouched spans stay displayed. Plain overlay segments use stable neighboring span identities. The production e2e sets its main page to explicit Paper/light so screenshot theme checks do not depend on the host's system theme.

Gates:

bun run check:

$ node scripts/check-type-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
Text sizes use shared role tokens.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/chips-416/apps/web
Getting Svelte diagnostics...

svelte-check found 0 errors and 0 warnings

bun run test:

 Test Files  125 passed (125)
      Tests  805 passed (805)
   Duration  105.25s (transform 50%, environment 19%, import 14%, tests 12%, setup 4%)

Vitest also printed jsdom scrollTo() and CSS parse diagnostics; the command exited 0.

Production build: ✓ built in 39.19s; adapter-static wrote the site to build. The bundler printed existing vendor use client and large-chunk warnings.

Production e2e:

composer e2e: all flows passed; screenshots in /home/kayg/Developer/calternal-wt/chips-416/target/composer-screens
CSP REPORTS composer: 0 across 11 pages

cargo build -p calternal-server: Finished dev profile [unoptimized + debuginfo] target(s) in 2m 22s. No Rust source changed, so Rust fmt/clippy/test gates were not run. cargo clean completed: Removed 6940 files, 4.3GiB total. Web build output was removed.

Frame strips are saved but are not attached to this issue: fj provides no issue attachment command, and the unauthenticated Forgejo asset upload returned HTTP 401. The four uncommitted, ignored files are target/composer-screens/{390,1440}-{paper,tokyo-night}-chip-stability-strip.png.

Finished #416 on `job/chips-416`. HEAD: `9f4259751f271061385a26b9cdd870f243864ea4` Root cause: `liveParse` exposed parsed runs only while their stored line exactly matched the current composer text, so the UI briefly had no chips during debounce and async parsing. Render keys also used changing parsed values, offsets, or segment indices, which remounted chips and highlight marks when text moved. Built: client-side span reconciliation keeps IDs for unaffected spans as edits shift offsets; only touched spans are re-evaluated, and extended matches retain the span identity. Rendering keeps the previous parse visible while the next parse is pending. The authoritative exact-line parse still controls commit. The unit tests and production e2e check that the time chip and its mark retain the same DOM nodes, including after a correction removes the run. Files: `apps/web/src/lib/composer/{Composer.svelte,Composer.svelte.test.ts,nlp.ts,runs.ts,runs.test.ts}`, `packages/ui/src/components/composer/HighlightOverlay.svelte`, `apps/web/e2e/composer.mjs`, and `apps/web/src/lib/components/app-sidebar.svelte` (guard nullable URLs during initial navigation; this removed two page errors found by the production e2e). Decisions where DESIGN was silent: client span IDs are assigned locally and reconciled by run kind plus overlap after rebasing through the UTF-16 text edit. A touched provisional span is dropped if the current parser result no longer recognizes it; untouched spans stay displayed. Plain overlay segments use stable neighboring span identities. The production e2e sets its main page to explicit Paper/light so screenshot theme checks do not depend on the host's system theme. Gates: `bun run check`: ``` $ node scripts/check-type-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json Text sizes use shared role tokens. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/chips-416/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` `bun run test`: ``` Test Files 125 passed (125) Tests 805 passed (805) Duration 105.25s (transform 50%, environment 19%, import 14%, tests 12%, setup 4%) ``` Vitest also printed jsdom `scrollTo()` and CSS parse diagnostics; the command exited 0. Production build: `✓ built in 39.19s`; adapter-static wrote the site to `build`. The bundler printed existing vendor `use client` and large-chunk warnings. Production e2e: ``` composer e2e: all flows passed; screenshots in /home/kayg/Developer/calternal-wt/chips-416/target/composer-screens CSP REPORTS composer: 0 across 11 pages ``` `cargo build -p calternal-server`: `Finished dev profile [unoptimized + debuginfo] target(s) in 2m 22s`. No Rust source changed, so Rust fmt/clippy/test gates were not run. `cargo clean` completed: `Removed 6940 files, 4.3GiB total`. Web build output was removed. Frame strips are saved but are not attached to this issue: `fj` provides no issue attachment command, and the unauthenticated Forgejo asset upload returned HTTP 401. The four uncommitted, ignored files are `target/composer-screens/{390,1440}-{paper,tokyo-night}-chip-stability-strip.png`.
Author
Owner

Merged into dev at c9738e9b4 (merged-tree web gates: 0 errors, 887/887).

Merged into dev at c9738e9b4 (merged-tree web gates: 0 errors, 887/887).
kayg closed this issue 2026-09-30 05:34:46 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#416
No description provided.