feat(mobile): active-now tray live sync, overflow expander, and screen-reader labels#4644
Conversation
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe only change since the last review is a whitespace/formatting adjustment in Files Reviewed (1 file)
Previous Review Summaries (3 snapshots, latest commit f64bb06)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit f64bb06)Status: No Issues Found | Recommendation: Merge Executive SummaryBoth previously flagged WARNINGs (missing Files Reviewed (4 files)
Previous review (commit 737dd94)Status: No Issues Found | Recommendation: Merge Executive SummaryBoth previously flagged WARNINGs (missing Files Reviewed (4 files)
Previous review (commit bab70a8)Status: 2 Issues Found | Recommendation: Address before merge Executive SummaryThe highest-risk issue is a functional gap in Overview
Issue Details (click to expand)WARNING
Files Reviewed (30 files)
Reviewed by claude-sonnet-5 · Input: 26 · Output: 4.6K · Cached: 522.5K Review guidance: REVIEW.md from base branch |
…nique Deliver sessions.heartbeat to every one of the user's web sockets via broadcastToWeb, mirroring the cli.connected/cli.disconnected pattern, and drop the subscriber-set special-case (a removed session id is detectable from its absence in the payload). Make aggregateSessions emit each session only under its current owner (sessionOwners.get(id) === connectionId), closing a latent duplicate-id hole after an ownership transfer with both CLIs still connected.
Add isConnected() and onConnectionChange() to UserWebConnection, driven by base-connection callbacks for live transitions and by the wrapper's own stopConnection()/destroy() for the final disconnected state (base destroy() emits no callback). Make retain() a required member of the type and update the implementers/doubles accordingly. Covers release-then-retain, no-callback-on-destroy, and a pong-timeout-triggered reconnect transition.
Add a single app-level ActiveSessionsLiveSync owner mounted once in the
authenticated layout. It retains the shared user web connection and
merges sessions.list / sessions.heartbeat / cli.connected /
cli.disconnected into the activeSessions.list query cache through one
serialized, non-blocking pipeline (cancelQueries + setQueryData). Cache
writes never await a fetch; every write cancels in-flight fetches, so a
late fetch can never overwrite a newer web-socket write.
Refreshes run via fetchQuery({ staleTime: 0 }) with durable per-reason
pending state, a rate-limited (>=10s) self-healing enrichment retry, and
exactly one refresh per disconnect->connect rising edge. The merge
preserves only createdOnPlatform/createdAt/updatedAt for known ids while
taking connectionId and liveness from the latest payload, is id-unique
latest-payload-wins, and drops parentSessionId rows. The list poll is
replaced with refetchInterval that is disabled only while the socket is
connected, via a useSyncExternalStore binding over the SDK connection
state.
Cap the collapsed tray at 3 rows and add an animated, reduced-motion- aware expander. selectTrayWindow returns the visible rows and hidden count; collapsed shows the first cap rows with a +N more control, expanded shows all rows with Show less. The section header keeps the total pinned count. The expander has a 44pt touch target and a screen-reader label, and layout transitions are suppressed under reduced motion while remaining functional.
Add a pure sessionRowAccessibilityLabel composer that mirrors the visible eyebrow content: title, then needs input when the needs-input eyebrow renders (meta omitted), then badge, then a speech-friendly meta that expands abbreviated time-ago units so VoiceOver reads 5 minutes ago rather than 5M AGO. Apply it to both the stored and remote session rows so tray and history rows read consistently. No visual changes.
Review follow-ups: detach() now fences queued writes, in-flight fetches, and trailing re-kicks so a disposed owner can no longer write the shared cache; enrichment refreshes are rate-limited after failed attempts too; cli.connected payloads are Zod-validated like every other system event.
A boolean detached flag reopens the fence when the same instance re-attaches (StrictMode remount): work queued under the previous attachment could resume and publish stale payloads. Replace it with a monotonically increasing attachment epoch captured at work creation.
…cheduling - onConnectionChange now no-ops on a destroyed connection, matching subscribeToCliSession, so late listeners cannot leak. - Enrichment pending-reason reconciliation is extracted into a non-kicking helper; processFetchQueue re-evaluates it after a successful fetch (so cli-connected/reconnect fetches resolving with unenriched rows schedule enrichment) while keeping a single kick source and the 10s retry rate-limit even after failed fetches.
737dd94 to
f64bb06
Compare
Implements the three approved follow-ups from #4626 (stacked on
feat/active-now-pinned-section; retarget tomainwhen #4626 merges).1. Live-sync the Active-now tray over the user web socket
session-ingest:UserConnectionDOnow broadcastssessions.heartbeatto every one of the user's web sockets (removed sessions are detectable by absence, so the subscriber special-case is gone) and the aggregate snapshot is owner-unique, so a session transferring between CLI connections never appears twice.cloud-agent-sdk:UserWebConnectionexposes a connection-state API (isConnected()/onConnectionChange()), with wrapper-owned state so full-release/destroy transitions are reported correctly.ActiveSessionsLiveSyncowner retains the shared WS and applies heartbeats/snapshots/CLI-connect/disconnect events to theactiveSessions.listcache through a serialized cancel-then-write pipeline (a stalled tRPC refetch can never clobber newer WS state). Explicit refreshes (reconnect, cli-connected, cli-disconnected, enrichment) are coalesced with durable pending reasons; enrichment fields (createdOnPlatform/createdAt/updatedAt) are preserved across WS payloads, and enrichment refetches are rate-limited to one per 10s regardless of outcome. All work is fenced per attachment epoch, so nothing publishes after unmount/remount. While the WS is connected the 10s tray poll is disabled; it resumes automatically when the socket drops.2. Cap the tray with a
+N moreexpanderCollapsed tray shows at most 3 rows (
ACTIVE_NOW_TRAY_CAP); more pinned sessions render a+N morecontrol that expands to all rows (Show lessto collapse). The expander disappears entirely at ≤3 sessions, and reduced-motion disables the Reanimated layout/fade transitions while keeping the behavior.3. Screen-reader labels for session rows
Both stored and remote rows now expose
"<title>[, needs input], <badge>[, <spoken meta>]"— the visible eyebrow content in render order. Relative times are spoken as words (28 minutes ago, never28M AGOletter-forms); the needs-input variant omits meta exactly as the visible row does. The expander is labeledN more active sessions/Show fewer active sessions.Verification
format,typecheck(tsgo),lint(oxlint),check:unusedgreen in every changed package.cli.connectedpayload validation, epoch-based fence for remount races) fixed in dedicated commits; final round returned no findings.+2 MOREat 5 sessions); accessibility labels verified via the view hierarchy for tray rows, history rows, and the expander.