Skip to content

ui(phase-2b): sync-queue — plan + implementation#185

Merged
intendednull merged 22 commits into
mainfrom
docs/plan-sync-queue
Apr 25, 2026
Merged

ui(phase-2b): sync-queue — plan + implementation#185
intendednull merged 22 commits into
mainfrom
docs/plan-sync-queue

Conversation

@intendednull
Copy link
Copy Markdown
Owner

@intendednull intendednull commented Apr 21, 2026

Summary

Translates docs/specs/2026-04-19-ui-design/sync-queue.md into a tracked
implementation plan AND lands the code in one branch.

  • Offline indicator strip, queue pill, inline queue note
  • Pull-to-reveal on mobile, dedicated sync-queue screen
  • Relay-signal button, reconnection toast, welcome-back banner
  • Closes the Phase 2a Pending→None state-flip gate (views.rs)

Backend: MessageStore::delivery_state trait, Network::relay_status / device_online. No new willow-state EventKind.

Test tiers (per CLAUDE.md decision tree):

  • messaging unit: DeliveryState + derivations
  • client unit: QueueMeta actor + QueueView projection
  • browser: phase_2b_sync_queue module in browser.rs
  • Playwright: multi-peer sync + mobile pull-to-reveal only

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

  • just fmt --check passes
  • just clippy zero warnings
  • cargo test -p willow-messaging
  • cargo test -p willow-client
  • just test-browser (CI)
  • just test-e2e-sync passes
  • phase-2a open checkbox now ticked

🤖 Generated with Claude Code

intendednull and others added 12 commits April 21, 2026 14:09
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>
@intendednull intendednull changed the title docs(plan): phase 2b — sync-queue implementation plan ui(phase-2b): sync-queue — plan + implementation Apr 21, 2026
intendednull and others added 8 commits April 21, 2026 15:39
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>
# 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
@intendednull intendednull merged commit 6bfd6fc into main Apr 25, 2026
5 checks passed
@intendednull intendednull deleted the docs/plan-sync-queue branch April 25, 2026 06:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant