Skip to content

Rework the mobile PWA home and compact shelves - #2704

Closed
brsbl wants to merge 25 commits into
mainfrom
bb/update-mobile-pwa-home-experience-thr_3f2n4iexkr
Closed

Rework the mobile PWA home and compact shelves#2704
brsbl wants to merge 25 commits into
mainfrom
bb/update-mobile-pwa-home-experience-thr_3f2n4iexkr

Conversation

@brsbl

@brsbl brsbl commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

The compact home put a capped three-thread Recent list below the composer in one page scroller, so the composer moved away from the thumb, the list did not signal additional content, and thread rows omitted provider, hierarchy, worktree, branch, and activity context. Both compact side surfaces behaved as overlays: the left navigation drawer covered and dimmed the page, while the right panel rose as a bottom drawer. Cold thread navigation could begin with missing metadata, route transitions had no pending affordance, and provider discovery repeated seconds-long host probes and uncached logo work.

What changed

  • Compact home now anchors the production composer at the bottom while realistic recent-thread rows scroll behind it. A partial row signals more content and the Recent label stays pinned to the scroll viewport.
  • Recent rows use production provider marks, parent/child hierarchy, status, project/worktree/branch metadata, and attention-time ordering.
  • The compact left navigation becomes a 76vw shelf: the live page translates right, keeps its state, and remains undimmed instead of being covered by an overlay.
  • The compact right panel moves from the bottom drawer to a right-side shelf: the live page translates left and remains visible. Layer 2 builds on this shelf for full-page tool tabs.
  • Slow navigation now gets delayed, minimum-duration route feedback; cold thread boots reuse persisted sidebar metadata instead of beginning blank.
  • Provider installed-state is cached for five minutes instead of re-probing provider bridges on every list, and hashed provider logos use immutable content-addressed URLs.
  • Ladle renders the real NewThreadPromptBoxUI inside the real compact-home composition with populated production fixtures.

No wire changes; HOST_DAEMON_PROTOCOL_VERSION is unchanged.

Screenshots

Chrome for Testing 151.0.7922.71, 393×852 at DPR 2, using the same seeded project, thread data, route, viewport, and settled-content state. Before is the merge base (f4bbc2fe8); after is this PR head (04d18307f).

Before — composer occupies the top and Recent ends after three sparse rows After — realistic recents scroll behind a bottom-anchored production composer

Sticky header while scrolled

At the PR head, the compact-home viewport is scrolled to scrollTop=360. The Recent header remains exactly at the viewport top while rows continue underneath and the composer remains anchored at the bottom.

How you verified

  • The exact-revision branch web app was driven from first paint through settled content in Chrome for Testing. The compact-home root measured 393×852 and the production composer measured 361×128 at the bottom.
  • After scrolling, the compact-home viewport measured top=318, height=534, scrollTop=360; the production Recent header measured top=318, proving it remained pinned to its own scroll viewport rather than the document body.
  • Ladle was restarted on port 61002. Compact Home renders two real production prompt boxes, each 359×128, inside non-zero 391×850 compact-home roots; the composer remains bottom-aligned after scrolling.
  • Mobile Recents renders five real component subtrees with row counts 3, 2, 5, 3, 3, provider glyphs for every row, and a working hierarchy collapse from five rows to two.
  • Every affected story ID was loaded and measured through its production component subtree with zero runtime exceptions. All remote CI gates pass; no local CI-equivalent command was run.

The soft-keyboard rise still requires a real coarse-pointer device, and provider logo URLs require the bb server rather than standalone Ladle.

BB-Thread-ID: thr_wftu7bh9ez

AGENT GENERATED

@brsbl
brsbl force-pushed the bb/update-mobile-pwa-home-experience-thr_3f2n4iexkr branch from 7c339a4 to 29f2e45 Compare August 29, 2026 21:33
brsbl added 18 commits August 30, 2026 19:06
The compact home stacked a capped three-thread "Recent" list underneath the
prompt box inside one page scroller, so the composer sat wherever the content
left it and the thread list was a dead end.

Compact viewports now render a dedicated overlay layout: the composer is
pinned to the bottom with an opaque backdrop, and every active thread scrolls
behind it. The list shows 4.5 rows at rest and 5.5 once the "Recent" label
sticks, so the partial row always signals more content. Rows gained a second
line (project, worktree branch, relative activity) and a tinted provider mark,
and the compact sidebar became a shelf: the panel sits beneath the page and
the page slides right to reveal it, instead of the panel sliding over the page.

Sorting stays on latestAttentionAt rather than updatedAt so the order matches
the unread glyph rendered on the same row; updatedAt moves on invisible
bookkeeping like title reindexing.

useSystemProviders now reads and writes the persisted provider list that
useSystemExecutionOptions already maintained. Without it the new rows showed a
generic glyph for the several seconds the provider query takes to settle, then
popped to the real logo. Rows render an empty tile rather than a wrong icon
while the list is still unresolved.

Desktop and non-compact behavior are unchanged: the shelf transform, z-index,
and layout switch are all gated behind the compact viewport.
PR #2386 put in-app navigation behind useTransition and shipped
useIsRouteNavigationPending so surfaces could show a lightweight cue instead of
appearing unresponsive. Nothing ever consumed that hook, so the route commit
was deprioritized with nothing replacing it on screen.

RouteNavigationIndicator consumes it and renders an indeterminate bar at the
top of the app. It waits 120ms before revealing and holds for 320ms once shown,
so fast navigations never flash and slow ones still get feedback. Measured
pending windows: 43ms unthrottled (suppressed), 208ms at 6x CPU throttle, and
1794ms at 20x (both shown).

The indeterminate sweep already existed for plugin installs, including its
reduced-motion fallback, so the utility is renamed from
animate-plugin-install-progress to animate-indeterminate-progress and shared
rather than duplicated.

This covers the deferred-commit gap only. The longer wait after a route commits
is the destination's own data loading and is addressed separately.
A cold PWA boot deep-linked into a thread replaced the whole thread view,
header and composer included, with RouteLoadingSkeleton until the network
returned. The thread's list entry was already available: useSidebarNavigation
keeps a localStorage-backed bootstrap that is read synchronously on boot. But
useThread's placeholder chain only searched caches under threadsQueryKey(), so
it never looked there and hasResolvedData stayed false.

findSidebarNavigationThreadPlaceholder consults the live sidebar navigation
cache first, then falls back to the persisted bootstrap so the very first
render of a cold boot resolves. Warm navigation already short-circuited this
way through the thread-list cache; cold boot now behaves the same.

The lift into ThreadResponse reuses liftThreadListPlaceholder, so fidelity
matches the existing warm path exactly. Threads beyond the persisted cap of 30
per project still fall back to today's skeleton.
The provider logo route hard-coded cache-control: no-store, so every render
pass refetched the SVG. Its sibling plugin asset routes already serve
content-addressed assets as immutable for a year when the request carries a
matching ?h= hash, falling back to no-store otherwise.

The provider logo could not join that scheme because the hash was dropped when
the registration was built: the branding asset carries one, but the icon was
narrowed to { bytes, contentType }. Provider registrations now carry the hash,
readPluginProviderIcon hashes the bytes it reads so both icon sources agree,
and the logo URL is emitted with ?h=<hash>.

Verified against the running server: a matching hash returns
"public, max-age=31536000, immutable", while a missing or wrong hash still
returns no-store.
The compact drawer opened over a 40% black scrim and took 90vw (capped at
320px), so on a 393px phone the page kept 73px and was dimmed out of use. The
reference behavior this drawer was modeled on keeps the page undimmed and gives
it a wider strip.

The scrim is now transparent. The layer itself stays so tapping the page still
dismisses the drawer, which is how it closed before; only the dimming is gone.

The drawer is capped at 76vw instead of 90vw, matching the reference's page-card
edge. On a 393px viewport that is 299px rather than 320px, taking the exposed
page from 73px to 94px. The 320px ceiling is unchanged, so wider compact
viewports are unaffected. getSidebarMobilePanelWidth reads the same fraction so
the swipe math stays in step with the CSS.

The drawer's scrollbar is hidden at rest and revealed on hover, following the
existing .transient-scrollbar idiom but keyed to hover rather than a scroll
event. It is scoped to [data-vaul-drawer-direction] so the persistent desktop
sidebar keeps its resting scrollbar. Only colors change, so the gutter is still
reserved and content does not reflow when the thumb appears.
buildMobileRecentThreads flattened every project's threads into one array and
sorted them by recency, so a child thread rendered as a peer of its parent and
the relationship was silently dropped. The dev fixture showed it plainly:
"Rework folder model" and its two children appeared as three unrelated rows.

The list now builds through buildChronologicalThreadList from @bb/client-core,
the same canonical tree the desktop sidebar uses, and flattens the resulting
nodes into rows honoring collapse state. Picking the chronological builder
rather than buildProjectThreadGroups is deliberate: it passes
groupEnvironmentThreads = false, so parent/child nesting arrives with no
worktree grouping. Environment grouping stays a separate axis keyed by
environmentId and collapsedEnvironmentIdsAtom, and mobile does not adopt it.

Three behaviors come from the shared builder rather than being reimplemented: a
child whose parent is absent from the list is promoted to a root, hidden threads
are filtered out (they previously leaked into mobile recents), and cycles are
guarded.

Expansion state reads and writes collapsedThreadIdsAtom, the same
localStorage-backed key the desktop sidebar uses, so collapsing a parent on
mobile collapses it on desktop and back. Rows indent with
getSidebarThreadRowPaddingLeft, 8px base plus 24px per level. Parents carry
SidebarChildToggleChevron inline after the title, which already renders
aria-expanded and stops click propagation so it does not trigger row
navigation. A collapsed parent ORs its hidden descendants' activity into its own
trailing glyph, mirroring ThreadRow.

getMobileRecentThreads now returns rows carrying depth and collapse state rather
than bare threads. The highlighted-thread hoist is gone: reordering a thread to
the top cannot survive tree structure, and a freshly created thread already
sorts first by latestAttentionAt. Highlight styling is unchanged.
The 28px provider tile was centered on the whole two-line text block, which put
it 12px below the title's optical center: its top edge landed near the title's
x-height and its bottom edge near the metadata baseline, so it read as belonging
to neither line.

Centering on the block is what ResourceRow does, but that precedent does not
transfer. ResourceRow pairs a 24px slot with a ~34px block (0.71); this row
pairs 28px with 48px (0.58), and the smaller the leading element is relative to
the block, the more it reads as floating. iOS subtitle cells and Material
two-line lists anchor icon-sized leading elements to the first line and reserve
centering for avatars large enough to span both. The tile identifies the thread
and belongs with its title, not with the secondary metadata line.

self-start pulls the tile to the row's content top and mt-1 recenters it on the
title's line box. Measured across every visible row, tile center now matches
title line center exactly (0.0px, was 12.0px). The text block keeps its existing
vertical centering in the 60px row.

The 4px derives from (60-48)/2 + 2 + 24/2 - 28/2, so it is tied to the current
row height and type scale.
GET /api/v1/system/providers issued a provider.health host RPC for every
installed-visibility provider on every request. Each probe spawns the provider
bridge on the daemon, measured at ~2.9s (daemon-reported handlerMs 2852-2888),
and they run three at a time, so the endpoint took ~3s warm and 4.2s cold
against 2ms for /health. Every app boot paid it, and until it resolved the
mobile recents rows had no provider marks.

The probes are load-bearing: providers registered with visibility "installed"
are hidden until their CLI is actually present, and the registry holds eight
providers while the endpoint returns five. Dropping the filter would surface
Grok, OMP and OpenCode to users who do not have them, so the answer is caching
it, not removing it.

The installed answer is now cached on the provider registry, keyed by host and
provider and validated against getRegistrationRevision, so registering or
removing any provider drops it. The registry already owned that revision
counter, and it is already threaded everywhere the listing runs, so this needs
no new dependency: routing the cache through AppDeps instead cascaded a type
change through the whole WorkSessionDeps graph for no benefit.

The cached value is the in-flight promise, matching createProviderNativeRootsCache,
so concurrent callers share one probe rather than starting their own. A failed
probe forgets its entry so the next request retries instead of caching an error.
Plugin settings changes invalidate the whole set alongside native roots.

Measured on the running server, five requests six seconds apart: 3.94s then
0.010s, 0.005s, 0.006s, 0.005s, with three health RPCs total instead of three
per request. Identical five providers returned throughout.
Nesting shipped with a collapse control but no way back: collapse a parent, then
create a thread under it, and the new thread renders nowhere. There is no row
and no indication it exists, because the flattener skips a collapsed parent's
subtree and nothing re-expands it. That is a gap introduced by adding collapse
without the reveal half, not a missing enhancement.

getMobileRecentAncestorIds walks parentThreadId from the highlighted thread up
to the root and returns the chain, mirroring the desktop sidebar's effect in
ProjectList. It is hop-limited by the thread count so a parent cycle terminates,
stops at an absent parent, and skips hidden threads. An effect drops those
ancestors from collapsedThreadIdsAtom whenever the highlighted thread changes,
returning the existing array when nothing was collapsed so the atom does not
re-render in a loop.

Only thread ancestors are expanded. Desktop also expands environment ids in the
same walk, which mobile has no equivalent for since the list is built with
groupEnvironmentThreads = false.

Verified in the running app that collapse still hides a subtree (23 rows to 21,
child gone, persisted to bb.sidebar.collapsedThreads). The reveal itself is
covered by unit tests, since triggering it live needs a real thread creation to
set lastCreatedThreadId.
A child row carried the same raised, bordered provider tile as a root row, so
nesting read as two equally weighted rows rather than a parent and its child.
Indentation was doing all the work.

Rows at depth 0 keep the current treatment. Deeper rows drop the tile chrome to
border-transparent and bg-transparent and render at opacity-70, an emphasis step
the sidebar already uses. The provider mark keeps its own tint, so the icon
still identifies the provider.

Nothing about the box changes: size-7, self-start and mt-1 are shared by both
states, and the border stays 1px rather than being removed, so with border-box
sizing the tile measures 28x28 either way and the title-line anchor holds.
Confirmed in the running app: root tiles report opacity 1 with the seam border
and raised background, child tiles opacity 0.7 with transparent border and
background, both 28x28. Checked in light and dark.
apps/app lint bans code comments; the tile-emphasis test added one. My local
lint run reported clean because turbo replayed a cached result from before the
test edit, so only CI caught it. Re-run with --force to verify.
On compact viewports the secondary panel rendered through
PersistentResponsiveDrawerShell: a bottom sheet at max-h-92dvh behind a
bg-black/40 scrim. The left sidebar now opens as a shelf that displaces an
undimmed page, so the two side panels behaved nothing alike.

CompactSecondaryPanelShelf mirrors the sidebar shelf on the right. It sits at
z-0 beneath the page, the page translates left by
--secondary-panel-width-mobile to reveal it, and a transparent layer over the
exposed page strip dismisses on tap. Escape closes it and it is inert while
closed. The width token matches the sidebar's min(76vw, 320px), so the page
displaces the same amount whichever side opens.

The page learns to displace through a module store mirroring
sidebar-mobile-drawer-visibility, which SidebarInset reads with
useSyncExternalStore to set data-panel-shelf. Left takes precedence: the right
shelf only displaces the page when the sidebar shelf is closed, so the two
translate axes cannot fight.

The shelf portals to document.body. Rendered in place it sat inside
[data-sidebar="inset"], and once that element takes a transform it becomes the
containing block for position: fixed descendants, so the shelf slid along with
the page instead of staying put (measured left: -204 rather than 94) and the
dismiss layer moved out from under the visible strip.

Desktop is untouched: renderAsDrawer is already gated on the compact viewport,
so wide layouts keep the inline resizable PanelGroup, no data-panel-shelf
attribute and no transform.

SecondaryPanelLayout's realization test mocked the drawer shell to capture its
settle callback; it now mocks CompactSecondaryPanelShelf, keeping the same
native-browser-view gating coverage.
Softening the child provider tile by dropping its border and background left
the icon column looking ragged. The boxes were never misaligned: tile-right to
title-left measured 10px on every row. What moved was where the icon visually
ends. A provider mark is 16px inside a 28px tile, so it stops 6px short of the
tile edge. A root row hides that with a visible border at the edge; a chromeless
child row does not, so its icon reads as ending 6px early and its title as
sitting 6px further out.

Children keep border-border-seam and bg-surface-raised, so every tile
terminates on the same visible edge and indentation is the only thing that
shifts. De-emphasis is now opacity alone, dropped from 70 to 60 to keep a
comparable step now that the box fades with the mark rather than disappearing.

Footprint, alignment and hit target are unchanged, and the measured gap stays
10px at both depths.
Both compact shelves sit at z-0 beneath the page: the sidebar spans 0-299 and
the right panel spans 94-393, overlapping across 94-299. The right shelf portals
to document.body and so paints later, which meant that opening the sidebar
exposed 205px of a closed right panel on top of the sidebar it was supposed to
reveal. The sidebar rendered with most of its width covered by the other
shelf's content.

The closed right shelf is now invisible, flipping back before the page starts
moving and only hiding 220ms after a close so it stays painted for the whole
return transition. Visibility keeps the subtree mounted, so content realization
and the native browser view gating are unaffected.

The sidebar needs no equivalent guard today: when the right panel opens, the
page uncovers 94-393 and the wider right shelf paints over the sidebar's
remaining band. That is positional luck rather than design, so the guard lives
on the shelf that actually bled.
Both shelves lift the center pane, but the pane carried shadow-2xl, which in
this scale is not a bigger blur - it is the hardest step: a zero-blur 2px line
at 0.38 alpha. On a full-height pane that line lands at the bottom edge, off
screen, so the pane read as a flat seam against the revealed shelf with no
elevation at all.

shadow-xl is the same 0.15-alpha family the rest of the app uses, and its
0px 8px 10px -1px layer bleeds a few pixels past the vertical edge, so the
shelf-facing side finally casts a soft falloff.

The corner radius already matched: rounded-l-xl and rounded-r-xl both resolve
to --radius-xl (12px), measured on the running app. The desktop pane rule on
this element is dead code - data-variant is hardcoded to "sidebar", so
peer-data-[variant=inset] never matches - which leaves --radius-xl as the
app's only window-scale radius. The test pins the pair so neither side drifts.
The right-panel toggle picked its icon from renderAsDrawer, so every compact
viewport rendered PanelBottom. That was correct while the compact panel was a
bottom drawer, but it stopped being true when the panel became a right shelf:
the button's label already said "right panel" while its glyph still pointed
down at a drawer that no longer exists.

The icon no longer varies, so the two selector helpers collapse to one
constant rather than keeping a parameter the callers must supply and the
function must ignore. renderAsDrawer still drives the surrounding layout and
is untouched.
Every tabbed-shell story rendered at desktop width with renderAsDrawer false,
so nothing exercised the tab strip inside the phone-width shelf the compact
panel now opens as. The existing overflow row carried five tabs at 640px,
which never gets narrow enough to show the real constraint.

CompactShelfTabs pins the stage to 299px, the --secondary-panel-width-mobile
value at a 393px viewport, and feeds it eighteen files spanning several icon
types plus one name long enough to truncate. At that width the strip measures
2628px of tabs against 93px of visible track, so scrolling, truncation, the
pinned slot, and the selected-tab-offscreen case are all reachable.

PanelStage takes the width as an option rather than growing a second stage
component, and the last row repeats the same fixture at desktop width so the
shelf-only behavior is comparable.
The right-panel glyph stopped varying by viewport, so ThreadDetailHeader's
useIsCompactViewport call lost its only consumer and left an unused binding
that fails the typecheck.
@brsbl
brsbl force-pushed the bb/update-mobile-pwa-home-experience-thr_3f2n4iexkr branch from 269c575 to c8da53a Compare August 31, 2026 02:06
brsbl added 7 commits August 30, 2026 19:09
The component root is "relative min-h-0 flex-1", so it only gets height as a
flex child. The story frame was a plain block, which left flex-1 inert: the
region measured 0 and every child is absolutely positioned, so the story
rendered blank.

Measured after the fix: region 850px, scroll viewport top 379px, composer
117px, recents visible behind the composer.
providersById was an empty Map, so every provider tile rendered as a blank
square and the stories could not show the tile alignment or the indented-row
treatment they exist to demonstrate. STORY_PROVIDERS_BY_ID supplies three
providers and a few threads now reference different ones, so parent and child
rows no longer share one glyph.

The compact home story was worse: it hand-rolled fake rows instead of using
RootComposeMobileRecents, so it demonstrated nothing about the real list. It
now renders the real component. That surfaced two further problems: the list is
md:hidden, so the story needs the same viewport override MobileStage uses, and
the "Sparkles" glyph is in no icon registry, so claude-code drew nothing.
The compact home story inlined its fixtures, which left nothing for the shelf
story to reuse and invited a second hand-drawn imitation. The stage, composer,
recents, and the md:hidden viewport override now live in one module that
renders the real components.
OverflowFade gained an lg size for the compact home, where recents scroll
behind the pinned composer, but the primitive had no story at all: the new size
was only visible through the surfaces that happen to use it. The story renders
the real component across its sizes, placements, and tones.
@brsbl brsbl changed the title Rework the mobile PWA home around a bottom-anchored composer Rework the mobile PWA home and compact shelves Aug 31, 2026
@brsbl

brsbl commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by focused native stack #2763: #2758#2759#2760#2761#2762. Replacement layer #2761 has cumulative content byte-for-byte identical to this PR head; review should continue on the focused stack.

@brsbl brsbl closed this Aug 31, 2026
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