What changed
The phone's session page rebuilt after docs/ui-prototypes/mobile/01-home-and-session.html (frames "Session page", working in dark and needing you in light).
Header. Glass back button on the left and a glass ⋯ button on the right. Under them sit the project and the title, nothing else: no picker chips, since provider, model, mode and effort belong to the composer.
- Tapping the title renames the session in place, in the title's own type. This only works when the environment is connected and advertises
renameSession; otherwise the title is plain text and cannot be tapped. - The ⋯ menu is a glass popover with these items:
- Rename
- Name it again (
regenerateSessionTitle) - Settle, or Move back to active
- Stop background work (only while background work is running)
- Session settings
- Every enabled item is a shared-client command. Anything the phone cannot do stays listed, dimmed and inert, with the reason:
- Live work cannot be settled
- This environment cannot …
- Session settings: "Not on the phone yet"
- When the reason is shared (a sample session, not connected), it is said once at the top of the menu.
- One command at a time: while one is out, the other items and the title wait (listed, dimmed, "Waiting for the last change to land"), and each command carries a token so only the current one writes the line under the title.
- A line under the title says what a command is doing ("Renaming…", shimmering) or why it failed, using the environment's words.
- The title and the session's place in the list are always what the environment lists afterwards. The phone never updates them itself.
Status line above the dock covers working, needs you / question, background, done and failed. The label comes from the listed session's status (the web sidebar's mapping). The time on the right is counted from the server's own stamps:
- working: elapsed since the open turn started
- waiting: "waiting 2m" since the request was asked
- background: elapsed since the last turn ended
- done / failed: "1h ago"
- failed also shows the environment's failure message
The clock ticks in its own small component, so nothing else re-renders with it.
Transcript
- User prompts are fills (bubble colour, 18/18/4/18 corners).
- Assistant markdown uses highlighted code fences, tables on a fill and quotes with a faint bar.
- Reasoning is one collapsed row, chevron and label: "Thinking" shimmers while live, "Thought for 12s" once it ends. A thought with no text yet keeps the same row; it is not a button and its chevron is dimmed.
- Runs of bare tool rows fold into work groups, with web's summary wording and the call count on the right. A turn that mounts settled folds its work under "Worked for 14s"; one the reader watched stream settles in place (see Scroll).
- Code fences named after inherited object properties (
constructor,__proto__) are plain text; the language lookups are Maps. - Images render, notices show as placeholder rows in the environment's words, and child sessions appear as cards that open the child read-only, in the order the shared listing gives them.
- Content runs under the glass dock with a soft fade, and the top edge fades under the header.
Scroll: reading order, following the end natively. The transcript is a normal (non-inverted), top-aligned FlatList. A short thread starts at the top of the page like the mockup's transcript; a long one opens at its newest row (initialScrollIndex on the last row, with a native scroll to the end as the fallback). The index is fixed when the transcript first has rows, not when the page mounts: a live page mounts before its snapshot lands, and the list mounts with the snapshot, so a 200-message session draws its newest rows first instead of walking up through its history. Rows that arrive later never move it.
Why not inverted: Android's MaintainVisibleScrollPositionHelper anchors the cell whose bottom passes the reader's offset and compensates only a change in that cell's top. In an inverted list the live reply's top never moves as it grows, so a reader scrolled up inside a reply taller than the screen was pushed by every new line. Splitting the live reply into several cells would still leave its growing tail cell with the same problem, and undoing the split at settle would jump. In reading order the root cause goes away:
- A reader who scrolled up is never moved. New words only land below everything already drawn, so nothing above their offset changes. That includes a reader inside the live reply: its first lines keep their place while its last line grows. No scroll call is made for them at all.
- A reader at the end is followed by one native
ScrollView.scrollToEnd({ animated: false })per content-size change, coalesced to one per frame (requestAnimationFrame). That is one native call when the content's height changes, not one per token; most tokens change no height and ask for nothing. Nothing follows while a finger is on the list (onScrollBeginDrag/onScrollEndDrag). The reader leaves the end only by moving up (a drag or its fling); content growth and the list's own scroll never take them off it. Within 48 px of the end counts as at the end. - The gesture is the reader's until it rests. Scroll events are throttled (32 ms, and Android throttles them separately from drag-end), so the drag-end event's own offset decides whether the reader left the end. A release thrown upward (
velocity.yread per platform: Android reports the finger's velocity, iOS the offset's) counts as having left the end untilonMomentumScrollEnd, whatever a late scroll event says. So growth and its frame can neither snap the reader back nor cut the fling short (a native scroll to the end would stop it). - Rows above the reader that measure again (virtualised back in) are held by
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}, which in this direction compensates exactly the rows above the anchor. - No jump when a reply settles. A reply the reader watched stream settles in place: its work groups stay as they were, and only labels and status marks change, none of which changes height. The fold under "Worked for 14s" is used when a turn mounts already settled (opened later, or scrolled back into view).
- The glass scroll-to-latest button shows past 200 px from the end and hides within 120 px. Tapping it is the one animated native scroll to the end, and following resumes.
Performance
- The markdown library used to build a parser, a style sheet and a renderer for every mounted text block. There is now one renderer per theme and an LRU of parsed trees for settled texts, so a row scrolled back into view is not parsed again. Streaming text is parsed for its render only, so it does not evict settled rows.
- Rows, parts and work groups are memoised by identity, so a token re-renders only the part it lands in.
Dock slot. SessionDock takes its content as children and reports its height. The transcript's end and the jump button sit above whatever is in the slot. Today the slot holds the existing read-only bar (dev had no composer on this page).
Android facts respected
- Everything with a pressed state uses
Press; there are no function styles on a rawPressable. - Colours are inline from
usePhoneTheme(). Glassis unchanged (tint-only on Android until real blur lands).- Inline code has no line height of its own, because on Android a span's line height resizes the whole line it sits on.
- No native config changed.
Transitions added, and how each is driven
- Shimmer (live reasoning, running work group label, "Working…", "Opening…", the header's busy note): one Reanimated
withRepeat(withTiming(opacity 1 → 0.4, 800 ms, inOut quad))loop per mounted label, on the UI thread. It starts on mount and is never restarted by a token or a re-render. - Status line state change: Reanimated
FadeIn200 ms, out-quad, as an entering animation keyed by the status mark. Only a change of state fades; the clock and detail update in place. - Scroll-to-latest button: Reanimated
FadeIn180 ms out-quad on enter andFadeOut150 ms in-quad on exit. It is driven by a flag the scroll handler flips (with hysteresis), so a scroll re-renders only the button. - Scroll to latest: native
ScrollView.scrollToEnd({ animated: true }), only on the button tap. Following the end while streaming is not animated: one non-animated nativescrollToEndper content-size change, coalesced to a frame (see Scroll). - ⋯ menu: Reanimated
FadeIn160 ms out-quad on enter andFadeOut120 ms in-quad on exit. - Work group / reasoning chevron: Reanimated
withTimingrotate 0° → 90°, 180 ms, out-quad, on the UI thread. Only a tap changes it. - Work group / reasoning body reveal: Reanimated
FadeIn180 ms out-quad, only when the reader opened it. A body shown from the start, or one scrolled back into view, just appears. - Status marks' spinners (working, background): the existing
StatusMarklinearwithRepeatrotation. This is not new here, just reused in the status line and tool rows.
Pressed states (opacity or fill) are instant style changes through Press, not animations. Nothing on the streaming path animates; there are no springs, no animated gradients and no per-frame blur.
Verification
-
Tests: 303 pass (252 on dev). New tests cover:
- the status line (including background timing)
- work-group folding and labels, and settling in place
- scroll anchoring against the real follower, driven by a layout model of the list: a reader inside a live reply taller than the screen is never moved by 40 growth steps (offset and the line at the top of the screen unchanged, no scroll call); a reader above the reply is never moved, even when it settles shorter; the end is followed with one coalesced native scroll per size change and nothing for a token that changes no height; nothing follows while a finger is down; a fling up that began within the slop leaves the end; the button brings the reader back and following resumes
- drag-end and fling ordering: growth and its frame between the drag end and the next scroll event, with a drag past the slop no scroll event reported yet, and with an upward throw from within the slop (no snap back, no scroll call, through to the fling resting); the per-platform velocity sign
- where a live session opens: the page mounts empty, then the 200-message snapshot lands and the list mounts at its newest row; a later row moves nothing; a short thread draws from the top
- the header chrome with deferred answers: other commands and the title are held while one is out, a failure stays until the next action or 6 s, and nothing writes after unmount
- fences named
constructor,__proto__,toStringandhasOwnProperty - child cards keep the listing's order
- menu item derivation (live work, settled, background, unsupported, unreachable)
- the live header's rename and settle through the shared mock client, plus capability gating
-
pnpm --filter @openmanager/mobile lintandtypecheck: clean. -
Prettier
--checkpasses on every file this branch changes. The fullapps/mobilecheck still flags files this branch does not touch (babel.config.js,tsconfig.json,assets/expo.icon/icon.json, the git-ignoreddist-web/, and CRLF working copies); those fail the same way on dev. -
Visual check: a fixture web export (
EXPO_PUBLIC_FIXTURE=1, built outside the repo) shot at 390×844 in headless Chrome with device emulation. Checked frames:- working (dark)
- needs you (light)
- done with code and table (dark)
- background with the ⋯ menu open (light)
- a 200-message thread scrolled up with the jump button (light)
- after the scroll rework: a short thread starts at the top (light), and the 200-message thread opens at its end (dark)
Each matches the mockup's layout in both schemes. The emulator was already in use, so this was not run on Android.
Review round
- Scroll anchoring inside the live reply (blocker): replaced the inverted list with a reading-order list that follows the end natively; reasoning above, tests against the real follower.
- Short threads pinned to the bottom: they now start at the top, like the mockup.
- Fence names resolving inherited properties (blocker): the lookups are Maps, with a plain fallback and regression tests.
- Child cards reordered on the phone (blocker): they filter and keep the shared listing's order.
- Overlapping header commands (P2): serialised, with an operation token, and a deferred-answer test of the chrome flow itself.
- "Thinking" row: now the same chevron-and-label row as "Thought for 12s".
- Follow-up round: the drag-end event's own metrics and an upward fling now decide before growth follows; the opening index is fixed when the snapshot lands, not at page mount.
Known gaps (left for the follow-ups)
- The docked composer (text field, picks row, stop button) and the composer becoming the prompt (Allow / Always / Deny in place) belong to the composer and pending-interaction work. The dock slot is ready for them; until then it shows the read-only bar. Session settings in the ⋯ menu stays disabled until the composer's pickers land.
- Rich tool rows (inputs, outputs, diffs, the
+41 −3counts in the mockup) and rich notice rows wait for the tool-payload and notice work. Tool rows show the environment's title and status, and notices show the environment's words. - Real Android blur for the glass waits on the per-screen blur-target restructure; glass is tint-only on Android today.
- Not measured on a device yet: smooth scrolling of a 200-message session on a mid-range Android phone, the end-follow and fling handling under real streaming and touch, and opening a long thread at its end (
initialScrollIndexwithoutgetItemLayout). The anchoring logic is tested in JS; the native pieces it relies on are the scroll view'sscrollToEndand its scroll and drag events. The fixture'ss-long-threadrow is there for the measurement.
🤖 Generated with Claude Code
🚀 Deploying Preview to Cloudflare 🚀
Preview Deployments by commit
View logs ↗
View logs ↗
View logs ↗