Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 27 additions & 28 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import type {
} from "@t3tools/contracts";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";

import { HeaderHeightContext } from "@react-navigation/elements";

import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
Expand Down Expand Up @@ -67,6 +69,7 @@ import {
} from "./homeThreadList";
import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions";
import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus";
import { WorkspaceStatusBar } from "./WorkspaceStatusBar";
import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status";

/* ─── Types ──────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -187,6 +190,7 @@ function HomeTopContentSpacer() {
/* ─── Main screen ────────────────────────────────────────────────────── */

export function HomeScreen(props: HomeScreenProps) {
const navigationHeaderHeight = useContext(HeaderHeightContext) ?? 0;
const [groupDisplayStates, setGroupDisplayStates] = useState<
ReadonlyMap<string, HomeGroupDisplayState>
>(() => new Map());
Expand Down Expand Up @@ -867,16 +871,6 @@ export function HomeScreen(props: HomeScreenProps) {
catalogState: props.catalogState,
projectCount: props.projects.length,
});
const connectionStatus =
shouldShowConnectionStatus && Platform.OS !== "ios" ? (
<View
className="absolute left-0 right-0 items-center"
style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
>
<WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
</View>
) : null;

if (!hasAnyThreads) {
return (
<View
Expand All @@ -899,7 +893,10 @@ export function HomeScreen(props: HomeScreenProps) {
<ActivityIndicator color={accentColor} />
</View>
) : null}
{shouldShowConnectionStatus && Platform.OS === "ios" ? (
{/* Kept inline here rather than pinned: with no threads on screen
this is the only thing explaining why the page is blank, and
there are no rows below it to reflow. */}
{shouldShowConnectionStatus ? (
<View className="mt-4">
<WorkspaceConnectionStatus
state={props.catalogState}
Expand All @@ -909,25 +906,27 @@ export function HomeScreen(props: HomeScreenProps) {
</View>
) : null}
</View>
{connectionStatus}
</View>
);
}

const listHeader = (
<>
{Platform.OS === "ios" ? null : <HomeTopContentSpacer />}
// Status lives in the pinned WorkspaceStatusBar below, not in here: as row 0
// it mounted and unmounted on every flip of the (inherently bouncy) sync
// signal, flashing the status and shoving every row under it down and back.
const listHeader = <>{Platform.OS === "ios" ? null : <HomeTopContentSpacer />}</>;

{shouldShowConnectionStatus && Platform.OS === "ios" ? (
<View className="pb-4">
<WorkspaceConnectionStatus
state={props.catalogState}
onPress={props.onOpenEnvironments}
variant="sidebar"
/>
</View>
) : null}
</>
// Pinned above the list and always mounted, so state changes swap text in
// place instead of moving rows. On iOS the list is what normally sits under
// the transparent nav bar, so the bar has to clear it explicitly; Android's
// header is already an in-flow sibling and needs no offset.
const statusBar = (
<View style={{ paddingTop: Platform.OS === "ios" ? navigationHeaderHeight : 0 }}>
<WorkspaceStatusBar
state={props.catalogState}
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.

);

// Project scoping lives in the header filter menu (no inline chip row on
Expand Down Expand Up @@ -990,6 +989,7 @@ export function HomeScreen(props: HomeScreenProps) {
if (threadListV2Enabled) {
return (
<View className="flex-1 bg-screen">
{statusBar}
<SwipeableScrollGateProvider enabled={swipeEnabled}>
<FlatList
data={threadListV2Items}
Expand Down Expand Up @@ -1029,13 +1029,13 @@ export function HomeScreen(props: HomeScreenProps) {
}}
/>
</SwipeableScrollGateProvider>
{connectionStatus}
</View>
);
}

return (
<View className="flex-1 bg-screen">
{statusBar}
{/* Sticky headers are deliberately not wired up: LegendList's JS sticky
implementation mispositions pinned headers at mount under iOS
automatic content insets (headers render one nav-inset too low until
Expand Down Expand Up @@ -1084,7 +1084,6 @@ export function HomeScreen(props: HomeScreenProps) {
}
/>
</SwipeableScrollGateProvider>
{connectionStatus}
</View>
);
}
81 changes: 81 additions & 0 deletions apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test";

import type { WorkspaceState } from "../../state/workspaceModel";
import {
isWorkspaceConnectionStatusBusy,
shouldShowWorkspaceConnectionStatus,
workspaceConnectionStatusLabel,
workspaceSyncStatusLabel,
} from "./workspace-connection-status";

function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
Expand All @@ -13,6 +15,7 @@ function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState
hasLoadedShellSnapshot: true,
hasPendingShellSnapshot: false,
hasReadyEnvironment: true,
readyEnvironmentCount: 1,
hasConnectingEnvironment: false,
connectingEnvironments: [],
connectionState: "connected",
Expand Down Expand Up @@ -85,3 +88,81 @@ describe("workspace connection status", () => {
expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads...");
});
});

describe("workspace status bar label", () => {
// The bar is always on screen, so a quiet workspace still has to say
// something useful — the plain connection label falls through to
// "Not connected", which would misreport a healthy sync.
it("reports what synced and when at rest", () => {
const state = workspaceState({
readyEnvironmentCount: 2,
latestCachedSnapshotReceivedAt: "2026-07-30T15:41:00.000Z",
});

expect(workspaceSyncStatusLabel(state)).toMatch(/^Synced 2 environments at .+$/);
});

it("singularises a lone environment", () => {
const state = workspaceState({
readyEnvironmentCount: 1,
latestCachedSnapshotReceivedAt: "2026-07-30T15:41:00.000Z",
});

expect(workspaceSyncStatusLabel(state)).toMatch(/^Synced 1 environment at .+$/);
});

it("omits the time when no snapshot timestamp was recorded", () => {
const state = workspaceState({ readyEnvironmentCount: 3 });

expect(workspaceSyncStatusLabel(state)).toBe("Synced 3 environments");
});

it("omits the time when the recorded timestamp is unparseable", () => {
const state = workspaceState({
readyEnvironmentCount: 1,
latestCachedSnapshotReceivedAt: "not-a-date",
});

expect(workspaceSyncStatusLabel(state)).toBe("Synced 1 environment");
});

it("defers to the connection label whenever there is something to report", () => {
expect(workspaceSyncStatusLabel(workspaceState({ hasPendingShellSnapshot: true }))).toBe(
"Syncing threads...",
);
expect(
workspaceSyncStatusLabel(
workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }),
),
).toBe("You are offline");
});
});

describe("workspace status busy-ness", () => {
// Only these get damped before rendering; everything else is a steady fact.
it("treats in-flight sync and reconnects as busy", () => {
expect(isWorkspaceConnectionStatusBusy(workspaceState({ hasPendingShellSnapshot: true }))).toBe(
true,
);
expect(
isWorkspaceConnectionStatusBusy(workspaceState({ hasConnectingEnvironment: true })),
).toBe(true);
});

it("does not treat a settled workspace as busy", () => {
expect(isWorkspaceConnectionStatusBusy(workspaceState())).toBe(false);
});

it("does not treat real faults as busy, so they surface immediately", () => {
expect(
isWorkspaceConnectionStatusBusy(
workspaceState({ networkStatus: "offline", hasPendingShellSnapshot: true }),
),
).toBe(false);
expect(
isWorkspaceConnectionStatusBusy(
workspaceState({ connectionError: "boom", hasPendingShellSnapshot: true }),
),
).toBe(false);
});
});
68 changes: 68 additions & 0 deletions apps/mobile/src/features/home/WorkspaceStatusBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ActivityIndicator, Pressable, View } from "react-native";

import { SymbolView, type SFSymbol } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { useThemeColor } from "../../lib/useThemeColor";
import type { WorkspaceState } from "../../state/workspaceModel";
import { useSettledWorkspaceStatusBusy } from "./use-settled-workspace-status";
import { workspaceSyncStatusLabel } from "./workspace-connection-status";

/** Leading glyph for a non-busy status; busy states render a spinner instead. */
function workspaceStatusSymbol(state: WorkspaceState): SFSymbol {
if (state.networkStatus === "offline") return "wifi.slash";
if (state.connectionError !== null || !state.hasReadyEnvironment) {
return "exclamationmark.triangle";
}
return "checkmark.circle";
}

/**
* Workspace connection / thread-sync status, pinned above the thread list.
*
* This deliberately sits *outside* the list's scroll view. It used to be the
* list's ListHeaderComponent — row 0 — so every sync-state flip mounted and
* unmounted a row, flashing the status and shoving every thread below it down
* and back. Pinned and always mounted, only its contents change.
*
* It also never goes away: at rest it reports what synced and when, so the bar
* answers "am I up to date?" on sight instead of only appearing mid-sync. The
* busy/quiet transition is settled first (see useSettledWorkspaceStatusBusy)
* because the underlying sync signal toggles faster than anyone can read.
*/
export function WorkspaceStatusBar(props: {
readonly state: WorkspaceState;
readonly onPress: () => void;
/** 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.

const iconColor = useThemeColor("--color-icon-muted");
const label = workspaceSyncStatusLabel(props.state);

return (
<View className={props.className}>
<Pressable
accessibilityHint="Opens environment settings"
accessibilityLabel={label}
accessibilityRole="button"
onPress={props.onPress}
className="flex-row items-center gap-2 rounded-xl bg-subtle px-3 py-2.5 active:opacity-70"
>
{isBusy ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<SymbolView
name={workspaceStatusSymbol(props.state)}
size={15}
tintColor={iconColor}
type="monochrome"
/>
)}
<Text className="min-w-0 flex-1 text-sm font-t3-bold text-foreground" numberOfLines={1}>
{label}
</Text>
<SymbolView name="chevron.right" size={11} tintColor={iconColor} type="monochrome" />
</Pressable>
</View>
);
}
56 changes: 56 additions & 0 deletions apps/mobile/src/features/home/use-settled-workspace-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vite-plus/test";

import {
planWorkspaceStatusSettlement,
STATUS_BUSY_ENTER_DELAY_MS,
} from "./use-settled-workspace-status";

describe("workspace status settlement", () => {
it("delays going busy instead of switching immediately", () => {
// The sub-second sync blips a healthy connection produces never survive
// this delay — which is the point; they used to strobe the indicator.
expect(
planWorkspaceStatusSettlement({
rawBusy: true,
settledBusy: false,
holdUntil: 0,
now: 1_000,
}),
).toEqual({ kind: "schedule", busy: true, delayMs: STATUS_BUSY_ENTER_DELAY_MS });
});

it("does nothing while the shown state already matches", () => {
expect(
planWorkspaceStatusSettlement({
rawBusy: true,
settledBusy: true,
holdUntil: 5_000,
now: 1_000,
}),
).toEqual({ kind: "hold" });
});

it("goes quiet immediately once the minimum-visible hold has elapsed", () => {
expect(
planWorkspaceStatusSettlement({
rawBusy: false,
settledBusy: true,
holdUntil: 900,
now: 1_000,
}),
).toEqual({ kind: "apply", busy: false });
});

it("defers going quiet for the remainder of the hold", () => {
// Shown at t=1000 with a 900ms floor; a sync that resolves at t=1200 still
// leaves the busy status up for the remaining 700ms rather than blinking.
expect(
planWorkspaceStatusSettlement({
rawBusy: false,
settledBusy: true,
holdUntil: 1_900,
now: 1_200,
}),
).toEqual({ kind: "schedule", busy: false, delayMs: 700 });
});
});
Loading
Loading