ui(phase-2b): sync-queue — plan + implementation#185
Merged
Conversation
Translates docs/specs/2026-04-19-ui-design/sync-queue.md into a checkbox-tracked implementation plan. Closes the Phase 2a Pending→None state-flip gate along the way. Sized for agentic execution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces `DeliveryState { Delivered, PendingAllRecipients, PendingSomeRecipients }` on the messaging store trait + `InMemoryStore` impl with `mark_pending` / `ack` / `ack_all` helpers. Re-exports at crate root. Permissive default (`Delivered` when no tracking entry) keeps all existing stores behaving as they did. Closes plan Task 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces `queue.rs` with `QueueSummary`, `ArrivedSummary`, `RelayStatus`, and two pure fns `derive_pending` + `derive_late_arrival`. 13 unit tests cover the QueueNote transition table in spec §Per-message queue note. Module is WASM-clean (no I/O, no timers). Closes plan Task 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `RelayStatus { Reachable, Unreachable, NotConfigured }` + two default-impl trait methods on `Network`. `IrohNetwork` reports `Reachable` iff relay was configured and endpoint came online at boot (30 s window); live-probe deferred per plan Open Questions §4. `MemNetwork` exposes `set_relay_status` + `set_device_online` stubs for deterministic tests. Closes plan Task 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces `state_actors::QueueMeta` with per-recipient outbound tracking, peer-presence history, inbound hint map, relay/device signals, arrival bucket queue, mark-read map, and `offline_since_tick` transition stamp. Caps history at 2048 / arrivals at 512 (drop-oldest). Spawns the actor in both `ClientHandle::new()` and `test_client()`, plumbs a `StateRef<QueueMeta>` through `SourceState` + `ClientViewHandle::queue_meta`. 5 unit tests (enqueue/ack drain, caps, offline stamp, per-peer counts). `_set_queue_depth` left on the legacy `PresenceMeta` path per §Ambiguity decisions — both signals co-exist until retry-queue pipeline lands. Closes plan Task 4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the `QueueNote::None` stub in `compute_messages_view` for real derivation via `queue::derive_pending` + `queue::derive_late_arrival`. Adds `QueueView` + `compute_queue_view` that aggregates `QueueMeta::outbound` into per-peer summaries, depth, peer_count, and oldest_at. Threads `queue_meta: &Arc<QueueMeta>` through `compute_messages_view` and both actor-spawn sites. Replaces the 3 stub `projection_queue_note_none_*` tests with 4 real Phase 2b cases + 3 `compute_queue_view` cases. Closes the Phase 2a gate at `docs/plans/2026-04-20-ui-phase-2a-message-row.md:490`. Closes plan Task 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tHandle Surfaces the Phase 2b sync-queue API on `ClientHandle`: - `queue_view()` returns a `QueueView` snapshot - `retry_queue()` stamps a fresh `last_attempt_at` on every outbound entry and emits `ClientEvent::QueueChanged` - `mark_queue_read(peer_id)` writes a local tick marker and emits `QueueChanged` - `set_relay_status` / `set_device_online` on `ClientMutations` emit `RelayStatusChanged` / `DeviceOnlineChanged` events and stamp the offline-since-tick transition Adds three `ClientEvent` variants (`QueueChanged`, `RelayStatusChanged`, `DeviceOnlineChanged`) and re-exports `QueueSummary`, `ArrivedSummary`, `RelayStatus` at crate root. Ships `crates/client/src/tests/queue.rs` with 11 tests (plan asked for 5). Closes plan Task 6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pState Web state wiring for Phase 2b: - `AppState.queue` bucket (QueueUiState) carries `view`, `relay_status`, `device_online`, `open` signals - `NetworkState.connection_state: ReadSignal<ConnectionState>` enum companion to the legacy string (`Connecting | Connected | Reconnecting | Offline`) - `event_processing.rs` pipes `ClientEvent::QueueChanged / RelayStatusChanged / DeviceOnlineChanged` into the new signals and keeps `connection_status` + `connection_state` in lockstep with `device_online` - `connect.rs` spawns a queue tick driver (advances `QueueMeta::now`, decays 24 h arrivals) + a WASM `window.online/offline` listener that routes to `ClientMutations::set_device_online` - `willow-agent::notifications` gains the three new ClientEvent JSON payloads (EVENT_TYPE_NAMES 28 → 31) Closes plan Task 7 except 7.4 browser test (deferred to consolidation task). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…banner Lands the core visible Phase 2b components: - `<OfflineStrip>` — amber status line; renders only when peer_count>0; opens sync queue on click; appends `· relay unreachable` when applicable - `<QueuePill>` — per-peer amber pill with disambiguated aria-labels (outbound-only / inbound-only / both) + 99+/500+ caps + PendingVerify suppression - `<InlineQueueNote>` — three-state (`Queued` / `JustDelivered` / `InboundHeld`) italic hint component with exact spec copy - `<ReconnectionToast>` — moss-accent toast on offline→online transition; 4 s auto-hide; dismissible - `<WelcomeBackBanner>` — 48 px moss banner when queue arrivals accumulate across an offline window Mounts the three root-level overlays (strip, toast, banner) in `app.rs`. Ships icons `icon_signal` + `icon_check_small` + CSS for all surfaces (foundation tokens only; reduced-motion paths). QueuePill member-list mount, ARIA-describedby wiring, `Notifier::dispatch` routing, and 60 s offline gate are deferred to the Task 17 copy/ARIA sweep + Task 18 consolidation. Closes Tasks 8, 9, 10, 16 partially; flagged deferrals recorded in the plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Integrates the Phase 2b components into the rendering path: - `member_list.rs` mounts `<QueuePill>` after the trust badge on each member row; zero-cost for idle peers thanks to the component's internal `<Show>` guard - `message.rs` swaps the Phase 2a static queue-note strings for `<InlineQueueNote>` so copy + ARIA live in one place; `Pending` → `InlineState::Queued`, `LateArrival` → `InlineState::InboundHeld` The `just-delivered` transient state (detected via a `Pending → None` signal diff + 30 s timer) is deferred to the Task 17 copy/ARIA sweep. Closes plan Tasks 9.3 + 10.3 + 10.5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minimal-but-functional implementation of the sync-queue screen spec: - Header: close `×`, `<h2>sync queue</h2>` + subtitle + inline relay signal indicator - Status card: `queue drained` vs `reaching out…` with willowPulse dot + reached/total peer count - Outbound / Inbound tabs with 2 px moss underline on active - Per-peer row list rendering `queue-pill` summaries - Recent-arrivals section (auto-hides when empty) with moss `synced` pills - Footer: `retry now` (disabled + aria-busy while in flight) + `mark as read locally` (inbound only) - Verbatim privacy footnote - No delete action anywhere Desktop mount via `RightRailWhich::SyncQueue` — mutually exclusive with Members / Pinned / Thread; toggled via `app.queue.open`. Mobile route + pull-to-reveal deferred to Task 15; full row anatomy (avatar, preview, elapsed time, expand-to-message) deferred to the retry-queue pipeline. Closes plan Tasks 11, 12, 13 partially. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the per-step checkboxes for Tasks 14, 15, 17, 18 per actual landed state. Landed items marked `[x]`; deferred items marked `[ ]` with inline `*(deferred: ...)*` notes explaining why + where the follow-up work lives. Landed: core backend (QueueMeta actor, DeliveryState trait, Network relay/device hooks, QueueView projection, ClientHandle retry/mark-read APIs, Phase 2a Pending→None gate closed); core visible UI (OfflineStrip, QueuePill on member rows, InlineQueueNote on messages, SyncQueueView screen with tabs+rows+recent arrivals+retry+footnote, ReconnectionToast, WelcomeBackBanner); WASM online/offline listener. Deferred: standalone `<RelaySignalButton>` popover, mobile pull-to-reveal gesture, 60 s gate on toast/banner, full browser test consolidation, Playwright E2E suite, `sync_queue_copy.rs` consolidation, focus-return stack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sync-queue work added four new `ClientEvent` variants (MuteChanged, QueueChanged, RelayStatusChanged, DeviceOnlineChanged) and bumped the `EVENT_TYPE_NAMES` table to 31 entries — but the e2e cover-all assertion was still pinned to 28 and the wire-format roundtrip test still covered only 27 variants. - Bump the e2e count assertion from 28 to 31 with a note explaining how to keep it in sync when variants are added. - Extend the `all_variants_produce_valid_json` unit test to exercise every variant in `EVENT_TYPE_NAMES`, pinning the event list length to the table length so the two stay synchronised. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 2b online / offline listener in `connect.rs` is already `#[cfg(target_arch = "wasm32")]`-gated, but its call sites use `wasm_bindgen::closure::Closure` + `wasm_bindgen::JsCast` and `navigator().on_line()` — none of which resolved because `willow-client`'s `Cargo.toml` did not list `wasm-bindgen` as a dep and the `Navigator` / `EventTarget` features were missing from `web-sys`. Add `wasm-bindgen = "0.2"` and the two `web-sys` features under the existing `[target.'cfg(target_arch = "wasm32")'.dependencies]` block. The dep stays WASM-only — `willow-client` remains UI-agnostic for native consumers. Matches the pattern already in use for `gloo-timers` and the presence / queue tick drivers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ffline Before this commit the reconnection toast fired on every `device_online` false → true transition — including the first-connect flip — which made the banner show up on fresh page loads with an empty queue. Spec §Reconnection toast + §Welcome-back banner pin a "≥ 60 s offline" gate. - Extend `QueueMeta` with a `last_offline_ticks: Option<Tick>` field that captures the duration of the most recent offline window at the moment of the offline → online flip (before `offline_since_tick` clears). Expose it verbatim on `QueueView` so UI components do not need to observe the pre-clear value. - Rewire `<ReconnectionToast>` + `<WelcomeBackBanner>` to read the field and suppress unless the offline window was ≥ 60 ticks. - Consolidate every sync-queue copy string + the 60 s gate into `crates/web/src/components/sync_queue_copy.rs` so surfaces stop retyping the same copy and the spec's `§Copy (exact)` table has a single mirrored source of truth. Toast + banner import the gate constant from the copy module. Tests: 3 new client tests (`offline_transition_captures_last_offline_ticks_for_gate`, `short_offline_transition_still_records_last_offline_ticks`, `last_offline_ticks_is_none_on_first_connect`) + 8 unit tests in `sync_queue_copy` for byte-exact copy coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finishes the copy-consolidation pass started with the module landing. Every user-facing string on the offline strip, queue pill, inline note, reconnection toast, and welcome-back banner now flows through `components::sync_queue_copy`; the spec's `§Copy (exact)` table has exactly one mirror in the codebase. Future copy polish is a single- file edit. No behavioural change — the rendered strings are byte-for-byte identical to the previous inline literals (locked in by the existing copy tests in `sync_queue_copy::tests`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan Task 14: graduate the inline relay indicator baked into the sync-queue header into a standalone `<RelaySignalButton>` with an anchored popover (desktop) / bottom sheet (mobile), per spec §Relay awareness. - Reachable → `--moss-3` static glyph. - Unreachable → `--amber` glyph with the 2 s `willowPulse` at 40 % intensity (reduced-motion path collapses to static 75 % opacity). - NotConfigured → `--ink-3` idle glyph; the button is not interactive (no popover to render). The popover surfaces the status label, the current count of direct-peer attempts in progress (derived from `QueueView::per_peer.len()`), and a `change relay in settings` link that opens the existing settings dialog until `settings-tweaks.md` ships a dedicated relay picker. Backdrop click + `aria-expanded` keep the popover keyboard- and AT-accessible. Mount: replaces the inline `<span>.sync-queue-view__relay` indicator in the `SyncQueueView` header so every relay surface in the UI goes through the same component. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Land the deferred browser-test coverage for the sync-queue surfaces
alongside a small refactor that makes them mountable in the headless
harness.
Refactor (`sync_queue_view.rs`):
- Defer the `WebClientHandle` context lookup into the retry /
mark-read click handlers. The screen now mounts for DOM
assertions without needing an iroh-backed client — matches the
existing `MessageView` harness pattern. Real deployments always
have the handle so production behaviour is unchanged.
- Drop a `.clone()` on the mark-read closure that clippy flagged
once the outer capture was removed.
Tests (`phase_2b_sync_queue` in `browser.rs`, 27 new cases):
- `OfflineStrip`: hidden-when-zero, plural copy, relay suffix,
ARIA contract.
- `QueuePill`: hidden-when-zero, outbound copy + aria-label,
99+ / 500+ clamp.
- `InlineQueueNote`: byte-exact copy for Queued / InboundHeld /
JustDelivered variants + `role=note` + aria id shape.
- `SyncQueueView`: header / subtitle, status card (drained +
reaching out), two tabs with outbound default, per-peer rows,
recent-arrivals visibility, retry button disabled when empty,
mark-as-read inbound-only, no-delete-action guard, verbatim
footnote.
- `ReconnectionToast`: 60 s gate — hidden without transition,
suppressed under 60 s, fires ≥ 60 s, dismiss button.
- `WelcomeBackBanner`: 60 s gate — hidden without transition,
suppressed under 60 s, fires with arrivals ≥ 60 s, dismiss.
- `RelaySignalButton`: idle / ok / warn classes, popover open on
click when reachable, no-op when NotConfigured.
Tier rationale: single-client DOM assertions only. Multi-peer sync
flows stay on Playwright per CLAUDE.md's "lowest tier that can
cover the behaviour" rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflect the follow-up landings against the plan: - Task 8–13, 16: check off the browser-test subtasks now that `phase_2b_sync_queue` landed in `crates/web/tests/browser.rs` with 27 `#[wasm_bindgen_test]` cases (inventoried inline per task). - Task 14: check off RelaySignalButton subtasks — standalone component + popover + aria + class variants + browser tests. - Task 16: mark the 60 s gate for the reconnection toast + welcome- back banner as landed (`QueueView::last_offline_ticks`). - Task 17: mark the copy consolidation landed (`sync_queue_copy` module, every sync-queue surface routes through it). - Task 18: tick `phase_2b_sync_queue` module landing; flag the Playwright spec as intentionally deferred per the test-tier rule (memory: Playwright is usually wrong for single-client assertions). Task 15 (mobile pull-to-reveal) stays unticked — legitimate gesture-arbitration blocker, banner note intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Takes HEAD's browser.rs (phase-2b sync-queue tests, 10335 lines) and appends the foundation_tokens module from origin/main (PR #184, Task 14) at EOF. Both test modules live independently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 22, 2026
# Conflicts: # crates/client/src/lib.rs
# Conflicts: # crates/client/src/lib.rs # crates/web/src/components/member_list.rs # crates/web/src/components/mod.rs # crates/web/src/state.rs # crates/web/style.css # crates/web/tests/browser.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Translates docs/specs/2026-04-19-ui-design/sync-queue.md into a tracked
implementation plan AND lands the code in one branch.
Backend:
MessageStore::delivery_statetrait,Network::relay_status / device_online. No newwillow-stateEventKind.Test tiers (per CLAUDE.md decision tree):
Scope
In: everything the sync-queue spec declares shippable.
Deferred: queue persistence, archive surface, inbound-hint heartbeat
wire, settings queue-limit UI (see plan §Ambiguity decisions).
Test plan
🤖 Generated with Claude Code