Skip to content

fix(mobile): pin the workspace status above the thread list - #5023

Closed
carTloyal123 wants to merge 1 commit into
pingdotgg:mainfrom
carTloyal123:fix/mobile-sync-status-bar
Closed

fix(mobile): pin the workspace status above the thread list#5023
carTloyal123 wants to merge 1 commit into
pingdotgg:mainfrom
carTloyal123:fix/mobile-sync-status-bar

Conversation

@carTloyal123

@carTloyal123 carTloyal123 commented Jul 30, 2026

Copy link
Copy Markdown

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 both HomeScreen and ThreadNavigationSidebar. Because hasSynchronizingShell re-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.

Idle Reconnecting
Before
After

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 needed readyEnvironmentCount on WorkspaceState (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

  • On iOS the bar needs an explicit HeaderHeightContext offset: 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.
  • The full-page empty state deliberately 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.
  • The Android floating overlay that duplicated this status is gone.

Verification

  • tsc --noEmit clean
  • vp lint clean on changed files
  • Mobile suite green (592 tests, 11 new covering the resting label and the settling logic)
  • Manually exercised on iOS Simulator as above

(Media lives on a separate branch so it stays out of this diff.)

Split from #5022 so the unrelated feature stays independently reviewable.

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>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e0e550a-b649-4932-9f12-5f2411209559

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jul 30, 2026
/** Gutter matching the surrounding list. */
readonly className?: string;
}) {
const isBusy = useSettledWorkspaceStatusBusy(props.state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.

Fix All in Cursor

❌ 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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c285be. Configure here.

state={catalogState}
onPress={props.onOpenEnvironmentSettings}
className="px-3 pt-1 pb-2"
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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}`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c285be. Configure here.

return remainingHold <= 0
? { kind: "apply", busy: false }
: { kind: "schedule", busy: false, delayMs: remainingHold };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c285be. Configure here.

state={catalogState}
onPress={props.onOpenEnvironmentSettings}
className="px-3.5 pt-2.5"
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5c285be. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

@carTloyal123

Copy link
Copy Markdown
Author

Slop/too big of a feature for now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant