fix(mobile): pin the workspace status above the thread list - #5023
fix(mobile): pin the workspace status above the thread list#5023carTloyal123 wants to merge 1 commit into
Conversation
The "Syncing threads..." status flashed in and out above the thread
list, and shoved every row down and back each time it did.
It was the list's ListHeaderComponent — row 0 — in both HomeScreen and
ThreadNavigationSidebar. Because `hasSynchronizingShell` re-enters
"synchronizing" on every websocket resubscribe and retries expected
failures every 250ms, a healthy connection still produced a stream of
sub-second flips, and each one mounted or unmounted a row.
The status is now pinned outside the scroll view and always mounted, so
only its contents change and the list never moves. Three changes make
that work:
- It never goes away. At rest it reports what synced and when ("Synced 2
environments at 1:20 PM") instead of going blank, so a quiet bar still
answers "am I up to date?" rather than leaving the user to infer it
from an absence. This needed `readyEnvironmentCount` on WorkspaceState
— a boolean could not carry the count — and reuses the snapshot
timestamp the model already tracked.
- Busy states show a spinner rather than a static glyph, which reads as
"data is moving" far more clearly. Static symbols are now only for
steady states: synced, offline, error.
- The busy/quiet transition is settled before rendering: it must persist
400ms to appear (longer than the 250ms retry cycle, so self-resolving
blips never surface) and holds 900ms once shown. Real faults bypass
both delays, since delaying those would hide a problem worth seeing.
The decision is a pure function so the timing is unit-tested.
On iOS the bar needs an explicit header-height offset: the list is what
normally sits under the transparent nav bar, so an in-flow sibling would
start at y=0 and overlap the status bar. Android's header is already an
in-flow sibling and needs none.
The full-page empty state keeps its inline status: with no threads on
screen it is the only thing explaining why the page is blank, and there
are no rows below it to reflow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| /** Gutter matching the surrounding list. */ | ||
| readonly className?: string; | ||
| }) { | ||
| const isBusy = useSettledWorkspaceStatusBusy(props.state); |
There was a problem hiding this comment.
🟡 Medium home/WorkspaceStatusBar.tsx:38
The status bar settles the busy/quiet spinner but leaves the label and icon un-settled, so a sub-400ms sync blip still flashes the text and glyph. label comes straight from workspaceSyncStatusLabel(props.state) and the icon from workspaceStatusSymbol(props.state), both reading the raw WorkspaceState, while only isBusy passes through useSettledWorkspaceStatusBusy. During a transient hasPendingShellSnapshot or reconnect blip, the label immediately switches to "Syncing threads..."/"Reconnecting...", workspaceStatusSymbol switches to exclamationmark.triangle, and then both revert — the rapid content flashing the settling logic is meant to suppress still happens, just without the spinner. Consider driving the label and symbol off the same settled state used for isBusy, or at least basing workspaceStatusSymbol on isBusy so the glyph does not flip during settled-quiet intervals.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/home/WorkspaceStatusBar.tsx around line 38:
The status bar settles the busy/quiet spinner but leaves the label and icon un-settled, so a sub-400ms sync blip still flashes the text and glyph. `label` comes straight from `workspaceSyncStatusLabel(props.state)` and the icon from `workspaceStatusSymbol(props.state)`, both reading the raw `WorkspaceState`, while only `isBusy` passes through `useSettledWorkspaceStatusBusy`. During a transient `hasPendingShellSnapshot` or reconnect blip, the label immediately switches to "Syncing threads..."/"Reconnecting...", `workspaceStatusSymbol` switches to `exclamationmark.triangle`, and then both revert — the rapid content flashing the settling logic is meant to suppress still happens, just without the spinner. Consider driving the label and symbol off the same settled state used for `isBusy`, or at least basing `workspaceStatusSymbol` on `isBusy` so the glyph does not flip during settled-quiet intervals.
| }} | ||
| /> | ||
| <View className="flex-1"> | ||
| <WorkspaceStatusBar |
There was a problem hiding this comment.
🟠 High threads/ThreadNavigationSidebar.tsx:1086
When NATIVE_LIQUID_GLASS_SUPPORTED is true, the native-chrome sidebar uses a transparent navigation header and relies on contentInsetAdjustmentBehavior: "automatic" on LegendList to offset its content. But WorkspaceStatusBar is a plain sibling above the list, not inside it, so it receives no automatic inset adjustment and renders at the top of the screen underneath the navigation bar and status bar — it's visually obscured and its tap target is covered. The pinned bar needs the same native-header top inset applied to it, not just the list.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx around line 1086:
When `NATIVE_LIQUID_GLASS_SUPPORTED` is true, the native-chrome sidebar uses a transparent navigation header and relies on `contentInsetAdjustmentBehavior: "automatic"` on `LegendList` to offset its content. But `WorkspaceStatusBar` is a plain sibling above the list, not inside it, so it receives no automatic inset adjustment and renders at the top of the screen underneath the navigation bar and status bar — it's visually obscured and its tap target is covered. The pinned bar needs the same native-header top inset applied to it, not just the list.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5c285be. Configure here.
| onPress={props.onOpenEnvironments} | ||
| className="px-4 pt-1 pb-2" | ||
| /> | ||
| </View> |
There was a problem hiding this comment.
Pre-glass iOS status double offset
High Severity
The WorkspaceStatusBar on iOS unconditionally applies navigationHeaderHeight as top padding. This offset is only necessary when the navigation header is transparent. On iOS devices with an opaque header, this creates an unintended large empty gap above the status bar.
Reviewed by Cursor Bugbot for commit 5c285be. Configure here.
| state={catalogState} | ||
| onPress={props.onOpenEnvironmentSettings} | ||
| className="px-3 pt-1 pb-2" | ||
| /> |
There was a problem hiding this comment.
Sidebar status underlaps glass header
High Severity
In the native-chrome sidebar path, WorkspaceStatusBar is mounted as an in-flow sibling above the list with no header-height offset. On liquid glass, SidebarNavigationShell uses a transparent header plus stacked search, so the bar starts at y=0 and sits under the nav chrome. It previously lived in ListHeaderComponent, where automatic content insets kept it clear.
Reviewed by Cursor Bugbot for commit 5c285be. Configure here.
| // A cold start that never recorded a snapshot time still reports the | ||
| // connection count rather than inventing a timestamp. | ||
| return syncedAt === null ? `Synced ${environments}` : `Synced ${environments} at ${syncedAt}`; | ||
| } |
There was a problem hiding this comment.
Resting label claims zero synced
High Severity
workspaceSyncStatusLabel falls through to Synced N environments whenever shouldShowWorkspaceConnectionStatus is false. That helper only treats a missing ready environment as a problem after a shell snapshot has loaded, so cold start, loading, and no-connection states with readyEnvironmentCount === 0 render as synced—with a checkmark—on the always-mounted sidebar bar.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5c285be. Configure here.
| return remainingHold <= 0 | ||
| ? { kind: "apply", busy: false } | ||
| : { kind: "schedule", busy: false, delayMs: remainingHold }; | ||
| } |
There was a problem hiding this comment.
Faults still wait out busy hold
Medium Severity
planWorkspaceStatusSettlement treats every busy→quiet transition the same and keeps the settled spinner for the remaining min-visible hold. Offline and connection errors clear rawBusy, but they do not bypass that hold, so the bar can keep spinning beside a live fault label for up to STATUS_BUSY_MIN_VISIBLE_MS.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5c285be. Configure here.
| state={catalogState} | ||
| onPress={props.onOpenEnvironmentSettings} | ||
| className="px-3.5 pt-2.5" | ||
| /> |
There was a problem hiding this comment.
Sidebar inset ignores always-on bar
Low Severity
The sticky sidebar header now always includes WorkspaceStatusBar, but the pre-measure fallback SIDEBAR_STICKY_HEADER_HEIGHT still reflects the older optional-status height. Until onLayout runs, topListInset is short and the first rows can sit under the status bar.
Reviewed by Cursor Bugbot for commit 5c285be. Configure here.
ApprovabilityVerdict: Needs human review 2 blocking correctness issues found. Multiple high-severity review comments identify unresolved bugs: the status bar may render under the navigation header on certain iOS configurations, and the resting label can misleadingly show 'Synced 0 environments' during cold start. These issues warrant human review before merging. You can customize Macroscope's approvability policy. Learn more. |
|
Slop/too big of a feature for now |


What changed
The "Syncing threads..." status flashed in and out above the thread list, and shoved every row down and back each time it did.
Why it happens
It was the list's
ListHeaderComponent— literally row 0 — in bothHomeScreenandThreadNavigationSidebar. BecausehasSynchronizingShellre-enters"synchronizing"on every websocket resubscribe and retries expected failures every 250ms, a healthy connection still produces a stream of sub-second flips, and each one mounted or unmounted a row.Before / after
iPhone 17 Pro, iOS 26.5, against a live workspace with two connected environments. Each column is the same screen: connected and idle, then with an environment dropped.
Before: idle shows nothing at all, so the first row sits at the top. The moment the connection drops, the status becomes row 0 and every thread shifts down ~115pt — then snaps back when it clears. That shove-and-snap, repeating on each sub-second sync flip, is the flash.
After: the bar is always there, so the first row sits at the same offset in both states. Nothing moves; only the bar's own contents change.
The fix
The status is now pinned outside the scroll view and always mounted. Three things make that work:
It never goes away. At rest it reports what synced and when —
Synced 2 environments at 1:20 PM— instead of going blank, so a quiet bar still answers "am I up to date?" rather than leaving the user to infer it from an absence. This neededreadyEnvironmentCountonWorkspaceState(a boolean couldn't carry the count) and reuses the snapshot timestamp the model already tracked.Busy states show a spinner, not a static glyph — it reads as "data is moving" far more clearly. Static symbols are now only for steady states: synced, offline, error.
The busy/quiet transition is settled before rendering. It must persist 400ms to appear — longer than the 250ms retry cycle, so self-resolving blips never surface — and holds 900ms once shown. Real faults bypass both delays, since delaying those would hide a problem worth seeing. The decision is a pure function (
planWorkspaceStatusSettlement) so the timing is unit-tested rather than implicit in a hook.Notes
HeaderHeightContextoffset: the list is what normally sits under the transparent nav bar, so an in-flow sibling starts at y=0 and overlaps the system status bar. Android's header is already an in-flow sibling and needs none.Verification
tsc --noEmitcleanvp lintclean on changed files(Media lives on a separate branch so it stays out of this diff.)
Split from #5022 so the unrelated feature stays independently reviewable.