Skip to content

fix(mobile): stable active-session order, unified list scroll, reachable mono blocks - #4778

Merged
iscekic merged 18 commits into
mainfrom
fix/mobile-list-order-and-open
Jul 27, 2026
Merged

fix(mobile): stable active-session order, unified list scroll, reachable mono blocks#4778
iscekic merged 18 commits into
mainfrom
fix/mobile-list-order-and-open

Conversation

@iscekic

@iscekic iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Three mobile fixes, stacked on fix/mobile-loading-polish (#4772).

Base note: this branch also carries a snapshot merge of feat/mobile-session-list-ux (#4773, open) at 0affde149, because the unified-scroll item rebuilds the container its scroll behaviors attach to. The PR diff transiently shows #4773's commits (including its two small apps/web schema/SDK lines) until the bases merge; GitHub collapses them. This branch's own changes are under apps/mobile/ plus one apps/web fix in cloud-agent-sdk/session-manager.ts (item 4).

1. Stable "Active now" ordering (d8bc2405f)

The Agents "Active now" tray and Home "Agent sessions" section reshuffled as data refreshed: nothing in the pipeline (ingest worker, router, live-sync updater, hook, consumers) imposed an order, and any refetch or websocket setQueryData could permute the array. Fixed once at the shared read boundary (useAgentSessions), which both surfaces inherit.

New pure sortActiveSessionsByCreatedAt:

  1. Rows with no parseable createdAt sort first, tie-broken by id ascending.
  2. Enriched rows sort by createdAt descending (newest first).
  3. Equal createdAtid ascending — a total order, so output never depends on input order (no reliance on Array.prototype.sort stability; input array never mutated).

The precise stability claim (do not overstate): a row lacking createdAt is placed at the top provisionally. When enrichment supplies createdAt (~1–2s later), the row moves to its true position — at most one reposition, within one enrichment cycle of the row appearing. For a brand-new session that position is already the top, so it does not move; a reactivated or orphaned session may move down once. No row that has had a createdAt for longer than one enrichment cycle ever moves. The reported defect (rows reshuffling on refresh) is that last sentence, and it is fully fixed; the bounded one-time reposition of a just-appeared row is an accepted residual, pinned by unit tests (A12/A13). A session the worker reports but that has no cli_sessions_v2 row never gets a createdAt and pins to the top indefinitely — accepted as a deviation: it is perfectly stable, and burying a live session at the bottom is strictly worse UX. Reconciling worker/DB divergence is out of scope.

The comparator keys strictly on a parseable createdAt (via parseTimestamp; unparseable is treated as missing, never epoch 0) and deliberately not on isEnriched's any-of-three-fields heuristic. Sorting at the client read boundary rather than the router is required because the live-sync owner mutates the query cache out-of-band and would overwrite any server-side ORDER BY.

Unit tests cover tie determinism, reversed-input identity, non-mutation, the unenriched buckets, and the two permitted one-time moves.

2. One continuous scroll on the sessions list page (d3f30c526)

The "Active now" tray was pinned above the history list, permanently consuming viewport. It now renders as the history SectionList's ListHeaderComponent and scrolls away with it in one continuous gesture. ScreenHeader, filter chips, and the search header stay pinned above the list.

Single render site (mandatory): the isLoading early return is gone — loading renders the same SectionList with empty sections and skeletons as ListEmptyComponent, so the tray's local +N more expanded state survives the loading→rows transition. The remaining early returns (full-screen error, first-use empty) are gated on !isLoading so a cold open with an empty cache cannot flash "No sessions yet"; ListEmptyComponent is isLoading ? skeletons : body-empty so a tray-first load shows skeletons under the tray instead of "No past sessions". The gating decision lives in a pure selectSessionListContentSurface selector with focused tests.

Preserves #4773's behaviors: scroll restoration on back-navigation (baseline captured RESTORED pre-change), committed-search reset-to-top, tab re-tap scroll-to-top, pull-to-refresh and pagination — re-verified on device in this PR's E2E.

3. Reachable monospace blocks (f674ff734)

Long lines in tool-card mono blocks were cut at the right edge and unreachable: Yoga shrank the mono Text to the card width, so the horizontal ScrollView's content equaled its viewport and there was nothing to scroll (drags did nothing). All 13 horizontal-scroll sites (plus the preparation output tail) are deduped into one MonoScrollBlock:

  • shrink-0 mono text keeps intrinsic width so the scroll has real content to deliver (the actual fix, not an affordance-only patch).
  • Visible horizontal scroll indicator when content overflows.
  • Gesture coexistence: directionalLockEnabled (iOS), nestedScrollEnabled (Android), canCancelContentTouches — a mostly-vertical drag keeps scrolling the conversation; a horizontal pan scrolls the block.
  • Height pinned from the content's measured layout (keyed to the displayed text so grown content remeasures) to avoid the RN 0.83 Fabric horizontal-ScrollView ~10× height-mismeasure hazard; pre-change device baseline recorded (expanded read-card mono viewport 152pt on a 375×667pt screen) and re-measured in E2E.
  • A visible "Truncated" marker when the existing 1000/2000-char cap applies (the cap was previously silent).

Command text (bash $ …, preparation setup command) intentionally stays soft-wrapped; markdown code fences are untouched (out of scope, they wrap intentionally); kilo-chat/** has zero diff.

4. Session-open empty flash — reproduced and fixed (bc44e50ea, a6bc40301)

A fourth reported defect — opening an in-progress session flashing an empty state — initially did not reproduce on the unmodified baseline and was dropped. That conclusion was wrong: the race only becomes visible when the messages page fetch is slower than the first CLI heartbeat. A committed E2E latency harness (bc44e50ea; E2E_LATENCY_* env knobs delaying tRPC queries / WS events, inert unless set) reproduces it on demand at 3s messages delay: skeleton (~0.5s) → "No messages yet" (~3s) → transcript, captured frame-level on video.

Root cause in cloud-agent-sdk/session-manager.ts (a6bc40301): onFirstActivity cleared isLoading on the first CLI heartbeat while the snapshot page fetch was still in flight. Fix: while a remote session's initial history replay is pending, onFirstActivity keeps isLoading; onReplayComplete (fired by the transport on every terminal snapshot outcome, success or failure) owns the clear. Regression tests cover the race plus the fallback and non-remote paths (171/171 green). This is the one apps/web change on this branch.

Device verification on this head with the same 3s delay in place (fresh verifier agent): non-empty session opens skeleton → transcript directly with zero "No messages yet" frames; a genuinely-empty session still reaches "No messages yet" (~3.5s, no skeleton hang). Evidence in the comments.

Verification

  • pnpm format && pnpm typecheck && pnpm lint && pnpm check:unused && pnpm test — green (230 files / 1924 tests).
  • Device E2E on iPhone SE (iOS 26.5) against a local stack with three concurrent live CLI sessions: ordering across pull-to-refresh / websocket status changes / the enrichment window (A1–A5), unified-scroll behaviors (C1–C7), and mono-block reachability / gesture coexistence / height-parity (D1–D11) — results in the comments.
  • Session-open fix E2E at a6bc40301 with 3s messages latency in place: skeleton → transcript directly (zero empty-state frames) for a non-empty session; genuine-empty session still shows "No messages yet" — results in the comments.

iscekic added 12 commits July 26, 2026 01:39
Replace the per-instance opacity pulse with a soft horizontal gradient
highlight driven by one refcounted module-level Reanimated clock shared
across all Skeleton instances. Reduced motion keeps the static muted
block; the component API is unchanged.
… load

The transient loading skeleton in the list header collapsed in the same
update that prepended an older page; FlashList's content-position
maintenance compensates prepended items but not the header height
change, producing a visible jump at the page boundary.

Suppress the placeholder at the render-model layer (loading maps to the
omitted banner when one is visible, else hidden) and start the older-page
fetch two viewports before the boundary so the prepend lands before the
user reaches it. Mirror the fix in the kilo-chat message list and remove
the now-unused isFetchingOlder prop end to end.
Match category and master-row placeholders to the settled row geometry,
and gate the master Switch and Enable-notifications CTA on a new pure
deriveGateSettled predicate so a fully enabled user never sees a
transient Enable card or a Switch with a transient wrong value while
the push-token queries settle.
@iscekic iscekic self-assigned this Jul 26, 2026
@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) E2E interim result (iPhone SE, iOS 26.5, local stack, 3 live CLI sessions): Item 1 (ordering) ACCEPT — A1-A5/A12 pass on both surfaces (order stable across pull-to-refresh ×3, websocket status changes, and the enrichment window; new sessions appear at top and stay). Item 3 (unified scroll) ACCEPT — C1-C7 pass (scroll restoration identical offsets, search/tab-re-tap to top, continuous tray+history scroll, expand survives refresh, pagination works expanded). Item 4 (mono blocks) REJECT on D2 — wide mono content still lays out at viewport width (hierarchy shows no node wider than 375pt), so horizontal pans reveal no line tails; D1/D5/D6/D8/D9/D10 pass. Diagnosis: on Fabric the ScrollView content container stretches the text to viewport width so shrink-0 has no effect; repair in flight using the self-start intrinsic-sizing pattern. Scoped re-verification of D2/D3/D4/D11 follows the repair.

@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) E2E final result (iPhone SE, iOS 26.5, local stack, 3 live CLI sessions) — head 68ba3dee9

Item 1 (deterministic active-session order): ACCEPT — A1–A5, A12 pass.
Item 3 (unified session-list scroll): ACCEPT — C1–C7 pass.
Item 4 (reachable mono blocks): ACCEPT — D2–D6, D10, D11 pass.

The interim D2 rejection (horizontal pans never reached the inner scroller — RN 0.83 Fabric nested-scroll negotiation failure inside the session FlashList) is resolved by 68ba3dee9: the mono scroller now uses react-native-gesture-handler's ScrollView with directional activation offsets (activeOffsetX/failOffsetY ±10). Scoped re-verification on device: wide output tails reachable (part079, dir59/file.ts, …TAIL00_END00…TAIL04), long-line edit diffs reachable with column alignment intact, vertical drags fail over to the conversation list, block horizontal offset preserved through vertical scrolls, block height materially unchanged vs pre-fix baseline (no 10× inflation), virtualization and pagination-to-top intact with multiple wide blocks on screen.

@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) E2E follow-up at a6bc40301: the session-open empty flash is now reproduced, fixed, and verified on device.

Reproduction (pre-fix, 68ba3dee9) — the earlier "did not reproduce" conclusion was wrong: it only becomes visible when the messages page fetch is slower than the first CLI heartbeat. With a new E2E latency harness (E2E_LATENCY_MESSAGES_MS=3000, delays cliSessionsV2.getSessionMessagesPage by 3s, committed as bc44e50ea), frame-level video of opening the live "CLI output test" session shows exactly the reported sequence: skeleton (~0.5s) → "No messages yet" (~3s) → transcript. Root cause traced to cloud-agent-sdk/session-manager.ts: onFirstActivity cleared isLoading on the first CLI heartbeat while the snapshot page fetch was still in flight, and nothing re-armed it.

Fix (a6bc40301) — while a remote session's initial history replay is pending, onFirstActivity no longer clears isLoading; onReplayComplete (which the transport fires on every terminal snapshot outcome, success or failure) now owns the clear. Regression tests cover the race and the fallback/non-remote paths; full session-manager.test.ts 171/171 green, apps/web typecheck green. Fresh reviewer pass over the fix diff: no valid findings.

On-device verification (this head, same 3s delay in place, fresh verifier agent, iPhone SE iOS 26.5 simulator):

  • Non-empty session open ("CLI output test", ACTIVE NOW): skeleton → transcript directly; Vision OCR on every 4fps frame in the critical window found zero "No messages yet" frames; transcript content confirmed rendered. PASS
  • Genuine-empty session open (ses_061480d9…, history: null): skeleton → "No messages yet" appears ~3.5s after open (does not hang on skeleton). PASS

Verdict: VERIFIED. No manual post-merge actions.

@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Standin code review at a6bc40301 (Kilobot review is unavailable; a fresh mobile-reviewer agent reviewed git diff origin/main...HEAD instead, prioritizing this branch's own commits).

Verdict: NO VALID FINDINGS.

Reviewed all six own commits:

Commit Scope
d8bc2405f sortActiveSessionsByCreatedAt + useAgentSessions boundary
d3f30c526 Active-now as ListHeaderComponent + selectSessionListContentSurface
f674ff734 MonoScrollBlock + tool-card / preparation adoption
68ba3dee9 RNGH ScrollView + activeOffsetX / failOffsetY
bc44e50ea E2E tRPC/WS latency harness
a6bc40301 cloud-agent-sdk isLoading vs remoteHistoryReplaying

Verified: latency harness is inert when env knobs are unset (e2eFetch === fetch, early WS return); base branches (#4772/#4773) contribute no blockers in the stacked surface; mobile unit suite green (1925 passed). Residual non-finding: the E2E-only URL prefix rule also matches other cliSessionsV2.get* procedures when latency env is set — harmless, production path stays identity.

This was the final review pass; no further review rounds are queued unless the head changes.

Base automatically changed from fix/mobile-loading-polish to main July 27, 2026 09:03
@iscekic
iscekic enabled auto-merge (squash) July 27, 2026 09:21
@iscekic
iscekic merged commit c45f6f0 into main Jul 27, 2026
67 checks passed
@iscekic
iscekic deleted the fix/mobile-list-order-and-open branch July 27, 2026 09:29
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.

2 participants