Skip to content

feat(webllm): model picker + consent gate + Dialog primitive - #143

Merged
Vaishnavi1709 merged 4 commits into
mainfrom
vk/model-picker-issue-64
Jun 23, 2026
Merged

feat(webllm): model picker + consent gate + Dialog primitive#143
Vaishnavi1709 merged 4 commits into
mainfrom
vk/model-picker-issue-64

Conversation

@Vaishnavi1709

Copy link
Copy Markdown
Collaborator

Summary

PR B of the M4 in-browser AI rewrite epic, completing Phase 2 (#64). PR A (#129) shipped the backend — typed registry, keyed engine cache, model-dimensioned telemetry. This PR adds the user-facing picker (Steps 5–6 of the issue): a Dialog primitive, a ConsentDialog for Restricted-Community models, the ModelSelector feature component mounted in ReconstructedResume, and a useModelSelection hook that persists selection + per-licenseType consent to localStorage (with cross-tab storage-event sync).

Carries PR A's deferred TODO: cross-model loadEngine calls now serialize through a single in-flight serialChain, and the cache is split into loadedEngines (eviction candidates) and pendingByModelId (in-flight, never evicted). Also closes #130 by replacing the two near-identical loading panels in RewriteButton / SectionRewrite with a shared ModelLoadProgress component.

Closes #64
Closes #130

Reuse analysis (per CLAUDE.md 3-tier rule)

  • Dialog (primitive, src/design-system/primitives/Dialog.tsx) — no existing modal primitive in @design-system; built on the native <dialog> element so focus trap, Esc, and ::backdrop come free. Controlled via the open prop. Re-exported via @design-system.
  • ModelSelector (feature, src/components/features/ModelSelector.tsx) — no existing model-picker surface; the rewrite surfaces (RewriteButton, SectionRewrite) consume a model id but don't expose selection. Picker is mounted inline at the top of the Experience section in ReconstructedResume, visible only in the rewrite context (returns null when WebGPU is unavailable, same pattern as the rewrite buttons).
  • ConsentDialog (feature) — composed on the new Dialog primitive; not a parallel modal implementation.
  • ModelLoadProgress (shared, src/design-system/shared/) — promoted from two hand-rolled LoadingPanel functions previously duplicated across RewriteButton.tsx and SectionRewrite.tsx. Both call sites now import the shared one.

Reviewer-pass fixes already applied

  • Cache-probe race with WebLLM's in-flight IndexedDB writes (row could flip to "Downloaded" mid-download) — probe now triggers on a lastCompletedAt bumped only after the load resolves successfully.
  • Consent freshness was depending on incidental re-renders — consentMap is now real React state, so recordConsent triggers the re-render that hasConsent needs.
  • Removed autoFocus from Accept (Decline is the safe consent default).
  • Inverted error fallback: friendly summary first, raw error in a <details> disclosure.
  • LICENSE_TYPES derived from MODEL_REGISTRY (no hardcoded list to maintain).
  • console.warn on unload failure so OOM-after-switch bug reports have something to investigate.

Pre-merge gates (carry over from PR A — handled by author on the issue)

  • Version-pin verification for @mlc-ai/web-llm (mechanical).
  • License vetting for Gemma 2 Terms of Use + Llama 3.2 Community License (human judgment; load-bearing now that the consent gate fires at runtime). Drafts in .claude/scratch/issue-64-prep/.

Test plan

  • npm run typecheck clean
  • npm run test green — 597/597 deterministic across 3 consecutive full-suite runs
  • npm run build green
  • Manually verified in npm run dev: pick each model; Restricted-Community picks fire the consent dialog; Decline reverts; cached vs uncached row labels correct; cross-tab storage sync works

…64)

Phase 2 of the M4 in-browser AI rewrite epic, closing #64. PR A shipped
the backend (typed registry, keyed engine cache, model-dimensioned
telemetry); this PR adds the user-facing picker and the consent gate
required for the Restricted-Community models in that registry.

Steps 5-6 of #64:

- New Dialog primitive at src/design-system/primitives/Dialog.tsx, built
  on the native <dialog> element so focus trap, Esc, and ::backdrop come
  free. Controlled via the open prop. Re-exported via @design-system.
- New ConsentDialog feature component on the Dialog primitive. Fires
  before any Restricted-Community model begins downloading. Per-model
  vendor link (licenseUrl), Accept/Decline buttons, decline defaults to
  initial focus per consent UX convention. Persistence handled by
  useModelSelection (per-licenseType in localStorage).
- New ModelSelector feature component. Lists the 3 registry models with
  name, tier, downloadSizeMb, licenseType. Marks cached vs uncached rows
  by probing IndexedDB via hasModelInCache. Cached label "Downloaded ·
  runs offline"; uncached label "Will download ~N GB (one-time)" — the
  spec's download-size warning that fires only on fresh downloads.
  Restricted-Community picks route through ConsentDialog. Inline
  download progress + a friendly-summary error surface with optional
  Technical details disclosure for the raw error.
- New useModelSelection hook. Persists selection + per-licenseType
  consent to localStorage. Consent mirrored into React state so
  re-render freshness doesn't depend on incidental coupling. `storage`
  event listener wires up cross-tab sync (Tab B mirrors Tab A's pick).
  Pure I/O functions (readPersistedModelId, etc.) exported for testing
  without a React harness.

Plus carrying PR A's TODO:

- Cross-model loadEngine calls now serialize through a single
  in-flight `serialChain`. PR A documented the race as unreachable in
  its consumer scope and deferred the fix to PR B's picker; PR B
  introduces the picker, so the chain is in. Pinned by a test that
  verifies two same-microtask cross-model calls execute sequentially.
- web-llm.ts cache shape split into loadedEngines (engines whose load
  finished — eviction candidates) and pendingByModelId (in-flight or
  chained — NOT eviction candidates, nothing to unload yet). This fixes
  a subtle pre-existing bug where evicting a pending entry would queue
  unload-on-resolve on a model the user just asked for.

And closing #130 along the way:

- New ModelLoadProgress shared component at
  src/design-system/shared/ModelLoadProgress.tsx replaces the two
  near-identical LoadingPanel functions previously hand-rolled in
  RewriteButton.tsx and SectionRewrite.tsx. Both consumers now import
  the shared one.

Consumer wiring:
- RewriteButton.tsx and SectionRewrite.tsx now read selectedModelId
  from useModelSelection (instead of hard-coding DEFAULT_MODEL_ID).
- ReconstructedResume.tsx mounts ModelSelector at the top of the
  Experience section. Picker returns null when WebGPU is unavailable
  (same pattern as RewriteButton/SectionRewrite).

Reviewer pass on the diff caught 2 blocking issues — both fixed:
- Cache probe was racing with WebLLM's in-flight IndexedDB writes
  during a download (could flip a row to "Downloaded" mid-download).
  Probe now triggers on a lastCompletedAt timestamp bumped only after
  the load resolves successfully.
- Consent freshness was depending on incidental re-renders. Now
  consentMap is a real React state, so recordConsent triggers the
  re-render that hasConsent's check needs.

Plus reviewer-flag fixes: removed autoFocus from Accept (Decline is the
safe consent default), inverted error fallback (friendly summary first,
raw error in a disclosure), derived LICENSE_TYPES from MODEL_REGISTRY
(no hardcoded list to maintain), and added console.warn on unload
failure so OOM-after-switch bug reports have something to investigate.

Gates: 597/597 tests deterministic across 3 consecutive full-suite runs,
typecheck clean, build green.

Two pre-merge gates from PR A carry forward (drafts in
.claude/scratch/issue-64-prep/, posted by hand on the issue):
- Version-pin verification (mechanical)
- License vetting for Gemma 2 Terms of Use + Llama 3.2 Community License
  (human judgment; this is the more critical of the two now that the
  consent gate fires at runtime)

Closes #64
Closes #130

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Vaishnavi1709
Vaishnavi1709 force-pushed the vk/model-picker-issue-64 branch from 1e7a877 to 6405558 Compare June 22, 2026 18:44
@Vaishnavi1709
Vaishnavi1709 requested a review from s-annam June 22, 2026 18:44

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: model picker + consent gate + Dialog primitive

Summary

High-quality PR B of the M4 epic. Picker + consent gate + Dialog primitive + shared ModelLoadProgress (closes #130), and it lands PR A's deferred cross-model serialization in web-llm.ts. Tests thorough, docstrings excellent, reuse analysis honored. No blocking issues. One notable lazy-load regression + one primitive footgun worth addressing.

Gates (run on checked-out branch): npm run typecheck clean · npm run test 668/668 green.

Spec alignment (#64 steps 5-6)

Requirement Status
Picker UI mounted near rewrite surface
Per-licenseType consent persistence + cross-tab sync
Consent gate fires before Restricted-Community load; decline reverts
Cached vs fresh-download row labels
Cross-model loadEngine serialization (PR A TODO)
Friendly-first error w/ raw detail disclosure

Highlights

  • serialChain + split loadedEngines/pendingByModelId is correct and cleanly reasoned — eviction now happens at our turn under serialization, killing the concurrent-cross-model race. The chain never rejects (inner try/catch resolves/rejects the separate slot), so a failed load can't wedge the queue.
  • useModelSelection storage I/O split into pure, registry-validated functions; safeGet/safeSet swallow sandboxed-storage throws. Strong coverage incl. cross-tab + deprecated-id fallback.
  • Genuine dedup of the two LoadingPanel copies into shared ModelLoadProgress.

Key findings (all non-blocking)

  1. [Suggestion - perf] ModelSelector eagerly imports @mlc-ai/web-llm on mount via probeCachedIds. The picker mounts for any WebGPU user viewing a parsed resume with bullets, so the multi-MB WebLLM chunk now downloads on resume render — before any rewrite intent. Defeats the project's lazy-load design (web-llm was previously fetched only on Rewrite click). See inline.
  2. [Suggestion] Dialog primitive double-fires onClose on programmatic close. See inline.
  3. [Nit] Stale-engine micro-race in loadEngine fast-path A. See inline.
  4. [Nit] ModelSelector docstring mentions a "Try again" button that isn't rendered (retry = re-click the errored row).

Verdict

COMMENT — no blocking items; tests green. Recommend addressing #1 before merge (real shipped-bytes regression against the lazy-load design); #2 hardens the new primitive for reuse; #3/#4 are cleanup.

Comment thread src/components/features/ModelSelector.tsx
Comment thread src/design-system/primitives/Dialog.tsx
Comment thread src/lib/webllm/web-llm.ts
…ose, fast-path doc

Resolves three review threads from s-annam on PR #143.

- ModelSelector: defer `probeCachedIds` (which does `await import("@mlc-ai/web-llm")`)
  from a mount-time effect to first picker interaction (`onPointerEnter` /
  `onFocus` on the container, plus a defense-in-depth `setProbed(true)` in
  `onPick` for touch/keyboard paths). Previously, any WebGPU user viewing a
  parsed resume with bullets paid the multi-MB WebLLM chunk download before
  any rewrite intent, defeating the project's dynamic-import design. Until
  first interaction every row shows "Will download ~N GB" (degrades gracefully
  for returning users); after interaction the post-load `lastCompletedAt`
  re-probe keeps newly-cached rows flipping to "Downloaded · runs offline".
- Dialog primitive: add an `isProgrammaticCloseRef` flag set true before the
  effect's own `dialog.close()`. A new `handleClose` swallows the redundant
  close event the native `<dialog>` emits in response, so consumers that toggle
  `open` and also do work in `onClose` (abort a request, free a resource) no
  longer see a spurious second invocation per user gesture. Current
  ConsentDialog unmounts rather than toggling, so the bug wasn't reachable —
  but the primitive shouldn't ship a reuse footgun.
- web-llm.ts: doc-only. Note on the `loadEngine` fast-path A that a queued
  cross-model load can subsequently evict + `.unload()` the engine returned
  here. Unreachable with the current single-picker consumer (rows disabled
  mid-load), but a future multi-consumer caller would need a lock around
  `chat.completions.create()`.

Gates: typecheck clean, 668/668 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Vaishnavi1709
Vaishnavi1709 requested a review from s-annam June 22, 2026 19:36

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on the checked-out branch — typecheck clean, 668/668 tests green, build per author. One critical functional bug plus supporting findings inline.

Headline (blocks merge): the picker selection never reaches the rewrite buttons in the same tab, so picking a model is a no-op for already-mounted RewriteButton / SectionRewrite instances. Detail on useModelSelection.ts.

Comment thread src/hooks/useModelSelection.ts
Comment thread src/lib/webllm/web-llm.ts Outdated
Comment thread src/hooks/useModelSelection.test.ts
Comment thread src/components/features/ModelSelector.tsx

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on the critical bug: the picker selection never reaches the rewrite buttons in the same tab (useModelSelection is per-instance useState; the storage event does not fire in the writing tab). Picking a model is a no-op for already-mounted RewriteButton / SectionRewrite instances — they load DEFAULT_MODEL_ID. See the inline thread on src/hooks/useModelSelection.ts. Fix = shared store (useSyncExternalStore or context). The two 🟠 (stale eviction claim + untested stateful layer) and 🟡 (dialog unmount) also stand.

Vaishnavi1709 and others added 2 commits June 22, 2026 17:04
Resolves four review threads from s-annam on PR #143.

🔴 Critical (useModelSelection.ts): selection writes never propagated to
already-mounted rewrite consumers in the same tab, because each call to
the hook held isolated `useState` and the `storage` event only fires in
OTHER tabs. Reduced rewrite buttons to silently using DEFAULT_MODEL_ID
regardless of the picker. Moved state to a module-level store consumed
via `useSyncExternalStore`; every write calls `notify()` synchronously so
all subscribers re-render with the new snapshot. The cross-tab `storage`
listener folds into the same notify path (registered lazily once).
`_resetPersistedModelSelectionForTesting` resets store state too.

🟠 Stale eviction claim (web-llm.ts + rewrite-bullet.ts + rewrite-section.ts):
the prior doc note claimed the fast-path-A eviction race was unreachable
because picker rows are disabled mid-load — but RewriteButton and
SectionRewrite are independent consumers, not gated by the picker's
loading state, so a bullet rewrite can fast-path to engine A while the
picker is downloading B. Added `acquireInference` / `releaseInference`
counter and a `pendingUnload` Map; `evictAllExcept` parks `.unload()` for
any model with positive inflight count and `releaseInference` drains the
parked unload on the last release. Both rewrite call sites wrap
`engine.chat.completions.create()` in `try/finally` so error paths still
release.

🟠 Untested stateful layer (new useModelSelection.integration.test.tsx):
the bug above lived in exactly the surface the existing pure-IO tests
deliberately don't cover. Added a jsdom-env integration test that
mounts two Probe consumers in one root and asserts a setSelectedModelId
(and a recordConsent) from consumer A is observed by consumer B. Uses
`react-dom/client` + React 19's `act` directly (no RTL). Required a
jsdom devDep and extending vitest's include to `*.test.tsx`.

🟡 Dialog unmount-while-open (ModelSelector.tsx): consent dialog used to
unmount via `consentModel && <…>` on accept/decline, skipping the Dialog
primitive's `dialog.close()` effect and stranding focus. Split
`consentModel` (data) from `consentOpen` (visibility): decline/accept
sets `consentOpen=false`, then a rAF effect nulls out `consentModel` one
frame later so the close effect has already restored focus by the time
the React tree unmounts.

Gates: typecheck clean, 670/670 tests green (+2 new integration), build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — model picker + consent gate + Dialog primitive. Clean, well-documented PR; reuse analysis is solid and the 3-tier placement (primitive/shared/feature) is correct.

Gates: typecheck ✅, build ✅, tests green on CI Node 20 (671/673 — the 2 local fails I hit are Node 25's native experimental localStorage shadowing jsdom, not a code defect).

Verified the two riskiest spots — both correct:

  • Dialog drives the native <dialog> imperatively via showModal()/close() in an effect — so focus-trap, ::backdrop, and Esc genuinely work (not the broken <dialog open> attribute path).
  • ConsentDialog wires onClose={onDecline} and Dialog's onCancel preventDefaults → Esc maps to Decline (the safe consent default). Initial focus on Decline.

Non-blocking follow-ups (not required for merge):

  • TOCTOU (narrow): between await loadEngine() resolving on the fast path and acquireInference() firing inside rewriteBulletWithLlm, a concurrent picker switch can evictAllExceptunload() the engine mid-use. Contained by serialChain + the UI's retry path; worth a tracked issue if cross-model switching during an active rewrite becomes common.
  • A few silent catch/void-promise spots (detectWebGpu, probe, safeSet) could log for diagnosability.
  • ConsentDialog renders model.licenseUrl as an href with no scheme guard — registry is static source so near-zero risk, but an https:// check is cheap defense.

None block. Nice work.

@Vaishnavi1709
Vaishnavi1709 merged commit 8c12fb0 into main Jun 23, 2026
2 checks passed
@s-annam
s-annam deleted the vk/model-picker-issue-64 branch June 25, 2026 04:24
s-annam added a commit that referenced this pull request Jun 25, 2026
* feat(webllm): model picker + consent gate + Dialog primitive (PR B of #64)

Phase 2 of the M4 in-browser AI rewrite epic, closing #64. PR A shipped
the backend (typed registry, keyed engine cache, model-dimensioned
telemetry); this PR adds the user-facing picker and the consent gate
required for the Restricted-Community models in that registry.

Steps 5-6 of #64:

- New Dialog primitive at src/design-system/primitives/Dialog.tsx, built
  on the native <dialog> element so focus trap, Esc, and ::backdrop come
  free. Controlled via the open prop. Re-exported via @design-system.
- New ConsentDialog feature component on the Dialog primitive. Fires
  before any Restricted-Community model begins downloading. Per-model
  vendor link (licenseUrl), Accept/Decline buttons, decline defaults to
  initial focus per consent UX convention. Persistence handled by
  useModelSelection (per-licenseType in localStorage).
- New ModelSelector feature component. Lists the 3 registry models with
  name, tier, downloadSizeMb, licenseType. Marks cached vs uncached rows
  by probing IndexedDB via hasModelInCache. Cached label "Downloaded ·
  runs offline"; uncached label "Will download ~N GB (one-time)" — the
  spec's download-size warning that fires only on fresh downloads.
  Restricted-Community picks route through ConsentDialog. Inline
  download progress + a friendly-summary error surface with optional
  Technical details disclosure for the raw error.
- New useModelSelection hook. Persists selection + per-licenseType
  consent to localStorage. Consent mirrored into React state so
  re-render freshness doesn't depend on incidental coupling. `storage`
  event listener wires up cross-tab sync (Tab B mirrors Tab A's pick).
  Pure I/O functions (readPersistedModelId, etc.) exported for testing
  without a React harness.

Plus carrying PR A's TODO:

- Cross-model loadEngine calls now serialize through a single
  in-flight `serialChain`. PR A documented the race as unreachable in
  its consumer scope and deferred the fix to PR B's picker; PR B
  introduces the picker, so the chain is in. Pinned by a test that
  verifies two same-microtask cross-model calls execute sequentially.
- web-llm.ts cache shape split into loadedEngines (engines whose load
  finished — eviction candidates) and pendingByModelId (in-flight or
  chained — NOT eviction candidates, nothing to unload yet). This fixes
  a subtle pre-existing bug where evicting a pending entry would queue
  unload-on-resolve on a model the user just asked for.

And closing #130 along the way:

- New ModelLoadProgress shared component at
  src/design-system/shared/ModelLoadProgress.tsx replaces the two
  near-identical LoadingPanel functions previously hand-rolled in
  RewriteButton.tsx and SectionRewrite.tsx. Both consumers now import
  the shared one.

Consumer wiring:
- RewriteButton.tsx and SectionRewrite.tsx now read selectedModelId
  from useModelSelection (instead of hard-coding DEFAULT_MODEL_ID).
- ReconstructedResume.tsx mounts ModelSelector at the top of the
  Experience section. Picker returns null when WebGPU is unavailable
  (same pattern as RewriteButton/SectionRewrite).

Reviewer pass on the diff caught 2 blocking issues — both fixed:
- Cache probe was racing with WebLLM's in-flight IndexedDB writes
  during a download (could flip a row to "Downloaded" mid-download).
  Probe now triggers on a lastCompletedAt timestamp bumped only after
  the load resolves successfully.
- Consent freshness was depending on incidental re-renders. Now
  consentMap is a real React state, so recordConsent triggers the
  re-render that hasConsent's check needs.

Plus reviewer-flag fixes: removed autoFocus from Accept (Decline is the
safe consent default), inverted error fallback (friendly summary first,
raw error in a disclosure), derived LICENSE_TYPES from MODEL_REGISTRY
(no hardcoded list to maintain), and added console.warn on unload
failure so OOM-after-switch bug reports have something to investigate.

Gates: 597/597 tests deterministic across 3 consecutive full-suite runs,
typecheck clean, build green.

Two pre-merge gates from PR A carry forward (drafts in
.claude/scratch/issue-64-prep/, posted by hand on the issue):
- Version-pin verification (mechanical)
- License vetting for Gemma 2 Terms of Use + Llama 3.2 Community License
  (human judgment; this is the more critical of the two now that the
  consent gate fires at runtime)

Closes #64
Closes #130

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webllm): address review on PR #143 — lazy probe, dialog double-close, fast-path doc

Resolves three review threads from s-annam on PR #143.

- ModelSelector: defer `probeCachedIds` (which does `await import("@mlc-ai/web-llm")`)
  from a mount-time effect to first picker interaction (`onPointerEnter` /
  `onFocus` on the container, plus a defense-in-depth `setProbed(true)` in
  `onPick` for touch/keyboard paths). Previously, any WebGPU user viewing a
  parsed resume with bullets paid the multi-MB WebLLM chunk download before
  any rewrite intent, defeating the project's dynamic-import design. Until
  first interaction every row shows "Will download ~N GB" (degrades gracefully
  for returning users); after interaction the post-load `lastCompletedAt`
  re-probe keeps newly-cached rows flipping to "Downloaded · runs offline".
- Dialog primitive: add an `isProgrammaticCloseRef` flag set true before the
  effect's own `dialog.close()`. A new `handleClose` swallows the redundant
  close event the native `<dialog>` emits in response, so consumers that toggle
  `open` and also do work in `onClose` (abort a request, free a resource) no
  longer see a spurious second invocation per user gesture. Current
  ConsentDialog unmounts rather than toggling, so the bug wasn't reachable —
  but the primitive shouldn't ship a reuse footgun.
- web-llm.ts: doc-only. Note on the `loadEngine` fast-path A that a queued
  cross-model load can subsequently evict + `.unload()` the engine returned
  here. Unreachable with the current single-picker consumer (rows disabled
  mid-load), but a future multi-consumer caller would need a lock around
  `chat.completions.create()`.

Gates: typecheck clean, 668/668 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webllm): shared store + eviction guard + dialog mount on PR #143

Resolves four review threads from s-annam on PR #143.

🔴 Critical (useModelSelection.ts): selection writes never propagated to
already-mounted rewrite consumers in the same tab, because each call to
the hook held isolated `useState` and the `storage` event only fires in
OTHER tabs. Reduced rewrite buttons to silently using DEFAULT_MODEL_ID
regardless of the picker. Moved state to a module-level store consumed
via `useSyncExternalStore`; every write calls `notify()` synchronously so
all subscribers re-render with the new snapshot. The cross-tab `storage`
listener folds into the same notify path (registered lazily once).
`_resetPersistedModelSelectionForTesting` resets store state too.

🟠 Stale eviction claim (web-llm.ts + rewrite-bullet.ts + rewrite-section.ts):
the prior doc note claimed the fast-path-A eviction race was unreachable
because picker rows are disabled mid-load — but RewriteButton and
SectionRewrite are independent consumers, not gated by the picker's
loading state, so a bullet rewrite can fast-path to engine A while the
picker is downloading B. Added `acquireInference` / `releaseInference`
counter and a `pendingUnload` Map; `evictAllExcept` parks `.unload()` for
any model with positive inflight count and `releaseInference` drains the
parked unload on the last release. Both rewrite call sites wrap
`engine.chat.completions.create()` in `try/finally` so error paths still
release.

🟠 Untested stateful layer (new useModelSelection.integration.test.tsx):
the bug above lived in exactly the surface the existing pure-IO tests
deliberately don't cover. Added a jsdom-env integration test that
mounts two Probe consumers in one root and asserts a setSelectedModelId
(and a recordConsent) from consumer A is observed by consumer B. Uses
`react-dom/client` + React 19's `act` directly (no RTL). Required a
jsdom devDep and extending vitest's include to `*.test.tsx`.

🟡 Dialog unmount-while-open (ModelSelector.tsx): consent dialog used to
unmount via `consentModel && <…>` on accept/decline, skipping the Dialog
primitive's `dialog.close()` effect and stranding focus. Split
`consentModel` (data) from `consentOpen` (visibility): decline/accept
sets `consentOpen=false`, then a rAF effect nulls out `consentModel` one
frame later so the close effect has already restored focus by the time
the React tree unmounts.

Gates: typecheck clean, 670/670 tests green (+2 new integration), build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Srinivas Annam <annam@annam.org>
s-annam added a commit that referenced this pull request Jun 28, 2026
* feat(webllm): model picker + consent gate + Dialog primitive (PR B of #64)

Phase 2 of the M4 in-browser AI rewrite epic, closing #64. PR A shipped
the backend (typed registry, keyed engine cache, model-dimensioned
telemetry); this PR adds the user-facing picker and the consent gate
required for the Restricted-Community models in that registry.

Steps 5-6 of #64:

- New Dialog primitive at src/design-system/primitives/Dialog.tsx, built
  on the native <dialog> element so focus trap, Esc, and ::backdrop come
  free. Controlled via the open prop. Re-exported via @design-system.
- New ConsentDialog feature component on the Dialog primitive. Fires
  before any Restricted-Community model begins downloading. Per-model
  vendor link (licenseUrl), Accept/Decline buttons, decline defaults to
  initial focus per consent UX convention. Persistence handled by
  useModelSelection (per-licenseType in localStorage).
- New ModelSelector feature component. Lists the 3 registry models with
  name, tier, downloadSizeMb, licenseType. Marks cached vs uncached rows
  by probing IndexedDB via hasModelInCache. Cached label "Downloaded ·
  runs offline"; uncached label "Will download ~N GB (one-time)" — the
  spec's download-size warning that fires only on fresh downloads.
  Restricted-Community picks route through ConsentDialog. Inline
  download progress + a friendly-summary error surface with optional
  Technical details disclosure for the raw error.
- New useModelSelection hook. Persists selection + per-licenseType
  consent to localStorage. Consent mirrored into React state so
  re-render freshness doesn't depend on incidental coupling. `storage`
  event listener wires up cross-tab sync (Tab B mirrors Tab A's pick).
  Pure I/O functions (readPersistedModelId, etc.) exported for testing
  without a React harness.

Plus carrying PR A's TODO:

- Cross-model loadEngine calls now serialize through a single
  in-flight `serialChain`. PR A documented the race as unreachable in
  its consumer scope and deferred the fix to PR B's picker; PR B
  introduces the picker, so the chain is in. Pinned by a test that
  verifies two same-microtask cross-model calls execute sequentially.
- web-llm.ts cache shape split into loadedEngines (engines whose load
  finished — eviction candidates) and pendingByModelId (in-flight or
  chained — NOT eviction candidates, nothing to unload yet). This fixes
  a subtle pre-existing bug where evicting a pending entry would queue
  unload-on-resolve on a model the user just asked for.

And closing #130 along the way:

- New ModelLoadProgress shared component at
  src/design-system/shared/ModelLoadProgress.tsx replaces the two
  near-identical LoadingPanel functions previously hand-rolled in
  RewriteButton.tsx and SectionRewrite.tsx. Both consumers now import
  the shared one.

Consumer wiring:
- RewriteButton.tsx and SectionRewrite.tsx now read selectedModelId
  from useModelSelection (instead of hard-coding DEFAULT_MODEL_ID).
- ReconstructedResume.tsx mounts ModelSelector at the top of the
  Experience section. Picker returns null when WebGPU is unavailable
  (same pattern as RewriteButton/SectionRewrite).

Reviewer pass on the diff caught 2 blocking issues — both fixed:
- Cache probe was racing with WebLLM's in-flight IndexedDB writes
  during a download (could flip a row to "Downloaded" mid-download).
  Probe now triggers on a lastCompletedAt timestamp bumped only after
  the load resolves successfully.
- Consent freshness was depending on incidental re-renders. Now
  consentMap is a real React state, so recordConsent triggers the
  re-render that hasConsent's check needs.

Plus reviewer-flag fixes: removed autoFocus from Accept (Decline is the
safe consent default), inverted error fallback (friendly summary first,
raw error in a disclosure), derived LICENSE_TYPES from MODEL_REGISTRY
(no hardcoded list to maintain), and added console.warn on unload
failure so OOM-after-switch bug reports have something to investigate.

Gates: 597/597 tests deterministic across 3 consecutive full-suite runs,
typecheck clean, build green.

Two pre-merge gates from PR A carry forward (drafts in
.claude/scratch/issue-64-prep/, posted by hand on the issue):
- Version-pin verification (mechanical)
- License vetting for Gemma 2 Terms of Use + Llama 3.2 Community License
  (human judgment; this is the more critical of the two now that the
  consent gate fires at runtime)

Closes #64
Closes #130

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webllm): address review on PR #143 — lazy probe, dialog double-close, fast-path doc

Resolves three review threads from s-annam on PR #143.

- ModelSelector: defer `probeCachedIds` (which does `await import("@mlc-ai/web-llm")`)
  from a mount-time effect to first picker interaction (`onPointerEnter` /
  `onFocus` on the container, plus a defense-in-depth `setProbed(true)` in
  `onPick` for touch/keyboard paths). Previously, any WebGPU user viewing a
  parsed resume with bullets paid the multi-MB WebLLM chunk download before
  any rewrite intent, defeating the project's dynamic-import design. Until
  first interaction every row shows "Will download ~N GB" (degrades gracefully
  for returning users); after interaction the post-load `lastCompletedAt`
  re-probe keeps newly-cached rows flipping to "Downloaded · runs offline".
- Dialog primitive: add an `isProgrammaticCloseRef` flag set true before the
  effect's own `dialog.close()`. A new `handleClose` swallows the redundant
  close event the native `<dialog>` emits in response, so consumers that toggle
  `open` and also do work in `onClose` (abort a request, free a resource) no
  longer see a spurious second invocation per user gesture. Current
  ConsentDialog unmounts rather than toggling, so the bug wasn't reachable —
  but the primitive shouldn't ship a reuse footgun.
- web-llm.ts: doc-only. Note on the `loadEngine` fast-path A that a queued
  cross-model load can subsequently evict + `.unload()` the engine returned
  here. Unreachable with the current single-picker consumer (rows disabled
  mid-load), but a future multi-consumer caller would need a lock around
  `chat.completions.create()`.

Gates: typecheck clean, 668/668 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webllm): shared store + eviction guard + dialog mount on PR #143

Resolves four review threads from s-annam on PR #143.

🔴 Critical (useModelSelection.ts): selection writes never propagated to
already-mounted rewrite consumers in the same tab, because each call to
the hook held isolated `useState` and the `storage` event only fires in
OTHER tabs. Reduced rewrite buttons to silently using DEFAULT_MODEL_ID
regardless of the picker. Moved state to a module-level store consumed
via `useSyncExternalStore`; every write calls `notify()` synchronously so
all subscribers re-render with the new snapshot. The cross-tab `storage`
listener folds into the same notify path (registered lazily once).
`_resetPersistedModelSelectionForTesting` resets store state too.

🟠 Stale eviction claim (web-llm.ts + rewrite-bullet.ts + rewrite-section.ts):
the prior doc note claimed the fast-path-A eviction race was unreachable
because picker rows are disabled mid-load — but RewriteButton and
SectionRewrite are independent consumers, not gated by the picker's
loading state, so a bullet rewrite can fast-path to engine A while the
picker is downloading B. Added `acquireInference` / `releaseInference`
counter and a `pendingUnload` Map; `evictAllExcept` parks `.unload()` for
any model with positive inflight count and `releaseInference` drains the
parked unload on the last release. Both rewrite call sites wrap
`engine.chat.completions.create()` in `try/finally` so error paths still
release.

🟠 Untested stateful layer (new useModelSelection.integration.test.tsx):
the bug above lived in exactly the surface the existing pure-IO tests
deliberately don't cover. Added a jsdom-env integration test that
mounts two Probe consumers in one root and asserts a setSelectedModelId
(and a recordConsent) from consumer A is observed by consumer B. Uses
`react-dom/client` + React 19's `act` directly (no RTL). Required a
jsdom devDep and extending vitest's include to `*.test.tsx`.

🟡 Dialog unmount-while-open (ModelSelector.tsx): consent dialog used to
unmount via `consentModel && <…>` on accept/decline, skipping the Dialog
primitive's `dialog.close()` effect and stranding focus. Split
`consentModel` (data) from `consentOpen` (visibility): decline/accept
sets `consentOpen=false`, then a rAF effect nulls out `consentModel` one
frame later so the close effect has already restored focus by the time
the React tree unmounts.

Gates: typecheck clean, 670/670 tests green (+2 new integration), build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Srinivas Annam <annam@annam.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants