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
42 changes: 41 additions & 1 deletion apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ import {
PROJECT_SORT_OPTIONS,
THREAD_SORT_OPTIONS,
} from "./home-list-options";
import { useSettledWorkspaceSyncTone } from "./use-settled-workspace-sync-tone";
import {
WorkspaceSyncStatusButton,
workspaceSyncToneSymbol,
} from "./WorkspaceSyncStatusButton";
import { workspaceConnectionStatusLabel } from "./workspace-connection-status";
import type { WorkspaceState } from "../../state/workspaceModel";

export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;

Expand All @@ -43,6 +50,9 @@ export function HomeHeader(props: {
readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
readonly onOpenSettings: () => void;
readonly onStartNewTask: () => void;
/** Workspace connection/sync state — surfaced as the header sync button. */
readonly catalogState: WorkspaceState;
readonly onOpenEnvironments: () => void;
}) {
if (Platform.OS === "android") {
return <AndroidHomeHeader {...props} />;
Expand Down Expand Up @@ -212,6 +222,14 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
</View>
</View>

{/* Sync/connection state — moved out of the thread list's scroll
view, where mounting and unmounting a list header row made it
flash and reflowed every row below it. */}
<WorkspaceSyncStatusButton
state={props.catalogState}
onPress={props.onOpenEnvironments}
/>

<ControlPillMenu
actions={menuActions}
isAnchoredToRight
Expand Down Expand Up @@ -298,18 +316,40 @@ function IosHomeHeader(props: HomeHeaderProps) {
...props,
listOrganization: !threadListV2Enabled,
});
// Native bar-button items can only carry an icon (no spinner child), so the
// settled tone picks an SF Symbol. Settling matters more here than in the
// React headers: each change re-enters navigation.setOptions.
const syncTone = useSettledWorkspaceSyncTone(props.catalogState);
const syncSymbol = workspaceSyncToneSymbol(syncTone);
const syncLabel = workspaceConnectionStatusLabel(props.catalogState);

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/HomeHeader.tsx:324

The native iOS sync button's accessibility label (syncLabel) is derived from raw props.catalogState, while its icon (syncSymbol) is derived from the settled syncTone. During the 900 ms minimum-visible hold after syncing completes, the button still displays the syncing icon but workspaceConnectionStatusLabel already returns "Not connected", so VoiceOver announces a status that contradicts the visible indicator. Derive the label from the same settled tone as the icon, or settle the label alongside it.

Also found in 2 other location(s)

apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx:70

The button's visible state comes from settled tone, but its accessibility label is recomputed from the raw props.state. During the 900 ms minimum-visible period after syncing has completed, the button still renders a spinner while workspaceConnectionStatusLabel commonly returns Not connected; analogous rapid transitions can label one settled status as another. Screen-reader users therefore hear a status that contradicts the rendered indicator. Derive the label from the settled tone or settle the label alongside it.

apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx:1040

syncLabel is derived from the raw catalogState while syncSymbol is derived from the settled tone. During the 900 ms minimum-visible hold after connecting/syncing resolves, the native header still displays the sync icon but its accessibility label has already changed (typically to &#34;Not connected&#34;), so VoiceOver announces a state that does not match the visible settled status. Derive the label from the same settled tone/state as the icon.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/home/HomeHeader.tsx around line 324:

The native iOS sync button's accessibility label (`syncLabel`) is derived from raw `props.catalogState`, while its icon (`syncSymbol`) is derived from the settled `syncTone`. During the 900 ms minimum-visible hold after syncing completes, the button still displays the syncing icon but `workspaceConnectionStatusLabel` already returns `"Not connected"`, so VoiceOver announces a status that contradicts the visible indicator. Derive the label from the same settled tone as the icon, or settle the label alongside it.

Also found in 2 other location(s):
- apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx:70 -- The button's visible state comes from settled `tone`, but its accessibility label is recomputed from the raw `props.state`. During the 900 ms minimum-visible period after syncing has completed, the button still renders a spinner while `workspaceConnectionStatusLabel` commonly returns `Not connected`; analogous rapid transitions can label one settled status as another. Screen-reader users therefore hear a status that contradicts the rendered indicator. Derive the label from the settled tone or settle the label alongside it.
- apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx:1040 -- `syncLabel` is derived from the raw `catalogState` while `syncSymbol` is derived from the settled tone. During the 900 ms minimum-visible hold after connecting/syncing resolves, the native header still displays the sync icon but its accessibility label has already changed (typically to `"Not connected"`), so VoiceOver announces a state that does not match the visible settled status. Derive the label from the same settled tone/state as the icon.


return (
<>
<NativeStackScreenOptions
optionsVersion={filterMenu.items}
// Header item factories are stabilized, so a tone captured inside one
// can't change the options signature on its own — version it explicitly
// or the sync button would keep its first icon forever. Compared by
// value (optionsSignature), so a fresh literal each render is fine.
optionsVersion={{ filterMenuItems: filterMenu.items, syncSymbol, syncLabel }}
options={{
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
...(syncSymbol === null
? []
: [
withNativeGlassHeaderItem({
accessibilityLabel: syncLabel,
icon: { name: syncSymbol, type: "sfSymbol" } as const,
identifier: "home-sync-status",
label: "",
onPress: props.onOpenEnvironments,
type: "button" as const,
}),
]),
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export function HomeRouteScreen() {
{/* Restore the compact title in case the split branch blanked it. */}
<NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
<HomeHeader
catalogState={catalogState}
onOpenEnvironments={() =>
navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
}
environments={environments}
projects={projectFilterOptions}
searchQuery={searchQuery}
Expand Down
38 changes: 10 additions & 28 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -862,15 +862,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 (
Expand All @@ -894,7 +885,11 @@ export function HomeScreen(props: HomeScreenProps) {
<ActivityIndicator color={accentColor} />
</View>
) : null}
{shouldShowConnectionStatus && Platform.OS === "ios" ? (
{/* Kept inline here (unlike the thread list, where this moved to the
header sync button): with no threads on screen there is nothing
else to explain why the page is empty, and nothing below it to
reflow when it appears. */}
{shouldShowConnectionStatus ? (
<View className="mt-4">
<WorkspaceConnectionStatus
state={props.catalogState}
Expand All @@ -904,26 +899,15 @@ export function HomeScreen(props: HomeScreenProps) {
</View>
) : null}
</View>
{connectionStatus}
</View>
);
}

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}
</>
);
// Connection/sync state is reported by the header's sync button
// (WorkspaceSyncStatusButton), not from inside this scroll view: mounting and
// unmounting it as row 0 flashed the indicator and reflowed every row below
// it each time the (inherently bouncy) sync signal flipped.
const listHeader = <>{Platform.OS === "ios" ? null : <HomeTopContentSpacer />}</>;

// Project scoping lives in the header filter menu (no inline chip row on
// mobile — the menu is the one filter surface).
Expand Down Expand Up @@ -1024,7 +1008,6 @@ export function HomeScreen(props: HomeScreenProps) {
}}
/>
</SwipeableScrollGateProvider>
{connectionStatus}
</View>
);
}
Expand Down Expand Up @@ -1076,7 +1059,6 @@ export function HomeScreen(props: HomeScreenProps) {
}
/>
</SwipeableScrollGateProvider>
{connectionStatus}
</View>
);
}
60 changes: 60 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 {
isTransientWorkspaceSyncTone,
shouldShowWorkspaceConnectionStatus,
workspaceConnectionStatusLabel,
workspaceSyncTone,
} from "./workspace-connection-status";

function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
Expand Down Expand Up @@ -85,3 +87,61 @@ describe("workspace connection status", () => {
expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads...");
});
});

describe("workspace sync tone", () => {
it("is idle while a ready environment is connected", () => {
expect(workspaceSyncTone(workspaceState())).toBe("idle");
});

it("reports offline ahead of every other state", () => {
const state = workspaceState({
networkStatus: "offline",
connectionError: "Could not reach Julius’s Mac mini",
hasPendingShellSnapshot: true,
hasReadyEnvironment: false,
});

expect(workspaceSyncTone(state)).toBe("offline");
});

it("reports a connection error ahead of in-progress work", () => {
const state = workspaceState({
connectionError: "Could not reach Julius’s Mac mini",
hasPendingShellSnapshot: true,
hasReadyEnvironment: false,
});

expect(workspaceSyncTone(state)).toBe("error");
});

it("reports connecting ahead of shell sync", () => {
const state = workspaceState({
hasConnectingEnvironment: true,
hasPendingShellSnapshot: true,
hasReadyEnvironment: false,
});

expect(workspaceSyncTone(state)).toBe("connecting");
});

it("reports shell catch-up as syncing", () => {
expect(workspaceSyncTone(workspaceState({ hasPendingShellSnapshot: true }))).toBe("syncing");
});

it("falls back to disconnected once a snapshot loaded without a ready environment", () => {
const state = workspaceState({ hasReadyEnvironment: false });

expect(workspaceSyncTone(state)).toBe("disconnected");
});

// Only these two are driven by the bouncy shell-sync signal, so only these
// get settled before reaching the header (useSettledWorkspaceSyncTone).
it("treats only connecting and syncing as transient", () => {
expect(isTransientWorkspaceSyncTone("connecting")).toBe(true);
expect(isTransientWorkspaceSyncTone("syncing")).toBe(true);
expect(isTransientWorkspaceSyncTone("offline")).toBe(false);
expect(isTransientWorkspaceSyncTone("error")).toBe(false);
expect(isTransientWorkspaceSyncTone("disconnected")).toBe(false);
expect(isTransientWorkspaceSyncTone("idle")).toBe(false);
});
});
96 changes: 96 additions & 0 deletions apps/mobile/src/features/home/WorkspaceSyncStatusButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ActivityIndicator, Pressable, View } from "react-native";

import { SymbolView, type SFSymbol } from "../../components/AppSymbol";
import { cn } from "../../lib/cn";
import { useThemeColor } from "../../lib/useThemeColor";
import type { WorkspaceState } from "../../state/workspaceModel";
import { useSettledWorkspaceSyncTone } from "./use-settled-workspace-sync-tone";
import {
workspaceConnectionStatusLabel,
type WorkspaceSyncTone,
} from "./workspace-connection-status";

/**
* SF Symbol for a settled tone, or null when the tone is shown as a spinner
* instead. Exported so the iOS native header-item builders (which can only
* carry an icon name, not a React child) can style their own button the same way.
*/
export function workspaceSyncToneSymbol(tone: WorkspaceSyncTone): SFSymbol | null {
switch (tone) {
case "offline":
return "wifi.slash";
case "error":
case "disconnected":
return "exclamationmark.triangle";
case "connecting":
case "syncing":
// Rendered as an ActivityIndicator in React headers; native header items
// fall back to this icon since they can't host a spinner.
return "arrow.clockwise";
case "idle":
return null;
}
}

/**
* Header button reporting workspace connection / thread-sync state.
*
* Replaces the old inline WorkspaceConnectionStatus row that lived inside the
* thread list's scroll view, where every sync-state flip mounted and unmounted
* a list header — flashing the indicator and reflowing every row beneath it.
* Here the button's slot is always occupied (idle renders an empty box of the
* same size), so state changes swap the icon without moving anything, and the
* tone itself is settled first (see useSettledWorkspaceSyncTone) so the
* inherently bouncy sync signal can't strobe the header.
*/
export function WorkspaceSyncStatusButton(props: {
readonly state: WorkspaceState;
readonly onPress: () => void;
/**
* "home" matches the Android home header's circular 44pt buttons;
* "sidebar-grouped" matches the split-view sidebar's shared capsule group.
*/
readonly variant?: "home" | "sidebar-grouped";
}) {
const tone = useSettledWorkspaceSyncTone(props.state);
const iconColor = useThemeColor("--color-icon");
const pressedBackgroundColor = useThemeColor("--color-subtle");
const variant = props.variant ?? "home";
const isGrouped = variant === "sidebar-grouped";
const sizeClassName = isGrouped ? "h-11 w-[50px] rounded-[22px]" : "size-11 rounded-full";
const symbol = workspaceSyncToneSymbol(tone);
const isBusy = tone === "connecting" || tone === "syncing";

// Idle keeps the slot — an empty box the exact size of the button — so the
// neighbouring header controls never shift when sync state changes.
if (tone === "idle") {
return <View className={sizeClassName} pointerEvents="none" />;
}

const label = workspaceConnectionStatusLabel(props.state);

return (
<Pressable
accessibilityHint="Opens environment settings"
accessibilityLabel={label}
accessibilityRole="button"
hitSlop={4}
onPress={props.onPress}
// Home mirrors the sibling filter/settings circles (bg-subtle); the
// sidebar variant drops its own chrome to sit inside the shared capsule.
className={cn(sizeClassName, "items-center justify-center", !isGrouped && "bg-subtle")}
style={({ pressed }) => (pressed ? { backgroundColor: pressedBackgroundColor } : undefined)}
>
{isBusy ? (
<ActivityIndicator color={iconColor} size="small" />
) : symbol ? (
<SymbolView
name={symbol}
size={isGrouped ? 20 : 18}
tintColor={iconColor}
type="monochrome"
/>
) : null}
</Pressable>
);
}
Loading
Loading