Simplify workspace reconnect gating

This commit is contained in:
Mohamed Boudra
2026-05-03 13:50:20 +07:00
parent b2247450f9
commit fa20f313d3
9 changed files with 1310 additions and 333 deletions
@@ -0,0 +1,247 @@
import { Text, View } from "react-native";
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { formatConnectionStatus } from "@/utils/daemons";
import type { WorkspaceRouteState } from "@/screens/workspace/workspace-route-state";
interface WorkspaceRouteStateActions {
onRetryHost: () => void;
onManageHost: () => void;
onDismissMissingWorkspace: () => void;
}
export function renderWorkspaceRouteGate(input: {
state: WorkspaceRouteState;
actions: WorkspaceRouteStateActions;
}): React.ReactNode {
switch (input.state.kind) {
case "loading":
return <WorkspaceConnecting hostName={input.state.hostName} />;
case "unreachable":
return (
<WorkspaceUnreachable
state={input.state}
onRetry={input.actions.onRetryHost}
onManageHost={input.actions.onManageHost}
/>
);
case "missing":
return (
<WorkspaceMissing
hostName={input.state.hostName}
onDismiss={input.actions.onDismissMissingWorkspace}
/>
);
case "ready":
case "reconnecting":
return null;
}
}
export function renderWorkspaceReconnectIndicator(input: {
state: WorkspaceRouteState;
onRetryHost: () => void;
}): React.ReactNode {
if (input.state.kind !== "reconnecting") {
return null;
}
return <WorkspaceReconnectBanner state={input.state} onRetry={input.onRetryHost} />;
}
function getWorkspaceHostStateTitle(
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
): string {
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
return `Connecting to ${state.hostName}`;
}
if (state.connectionStatus === "offline") {
return `${state.hostName} is offline`;
}
return `Cannot reach ${state.hostName}`;
}
function getReconnectBannerCopy(
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>,
): string {
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
return `Reconnecting to ${state.hostName}...`;
}
return `${state.hostName} is offline`;
}
function WorkspaceConnecting({ hostName }: { hostName: string }) {
const { theme } = useUnistyles();
return (
<View style={styles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<View style={styles.textStack}>
<Text style={styles.title}>Loading workspace</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
</View>
);
}
function WorkspaceUnreachable({
state,
onRetry,
onManageHost,
}: {
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>;
onRetry: () => void;
onManageHost: () => void;
}) {
const { theme } = useUnistyles();
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
return (
<View style={styles.emptyState}>
{state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : null}
<View style={styles.textStack}>
<Text style={styles.title}>{getWorkspaceHostStateTitle(state)}</Text>
<Text style={styles.description}>
Host status: {formatConnectionStatus(state.connectionStatus)}
</Text>
{state.lastError ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Text style={styles.error} numberOfLines={3}>
{state.lastError}
</Text>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.errorTooltip}>{state.lastError}</Text>
</TooltipContent>
</Tooltip>
) : null}
</View>
{canRetry ? (
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
Retry
</Button>
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
Manage host
</Button>
</View>
) : null}
</View>
);
}
function WorkspaceReconnectBanner({
state,
onRetry,
}: {
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>;
onRetry: () => void;
}) {
const { theme } = useUnistyles();
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
const showSpinner = state.connectionStatus === "connecting" || state.connectionStatus === "idle";
const isErrorState = state.connectionStatus === "offline" || state.connectionStatus === "error";
return (
<View style={styles.reconnectBanner} testID="workspace-reconnect-banner">
{showSpinner ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
<Text style={isErrorState ? styles.reconnectTextDestructive : styles.reconnectText}>
{getReconnectBannerCopy(state)}
</Text>
{canRetry ? (
<Button size="sm" variant="outline" leftIcon={RotateCw} onPress={onRetry}>
Retry
</Button>
) : null}
</View>
);
}
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
return (
<View style={styles.emptyState}>
<View style={styles.textStack}>
<Text style={styles.title}>Workspace not found</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
Back
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
textStack: {
alignItems: "center",
gap: theme.spacing[2],
maxWidth: 520,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: Math.round(theme.fontSize.sm * 1.4),
textAlign: "center",
},
errorTooltip: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
maxWidth: 420,
},
actions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
reconnectBanner: {
minHeight: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
reconnectText: {
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
reconnectTextDestructive: {
minWidth: 0,
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
}));
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { resolveWorkspaceRouteState } from "./workspace-route-state";
function createWorkspaceDescriptor(): WorkspaceDescriptor {
return {
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
workspaceDirectory: "/repo/project",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
diffStat: null,
scripts: [],
archivingAt: null,
};
}
describe("resolveWorkspaceRouteState", () => {
it("returns unreachable when no descriptor is cached and the host is offline", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: false,
}),
).toEqual({
kind: "unreachable",
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
});
});
it("keeps offline routes unreachable after workspace hydration", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: true,
}),
).toEqual({
kind: "unreachable",
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
});
});
it("returns reconnecting when the descriptor is cached and the host is offline", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
}),
).toEqual({
kind: "reconnecting",
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
});
});
it("returns missing after workspace hydration when the host is online", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
}),
).toEqual({ kind: "missing", hostName: "Laptop" });
});
it("returns loading before workspace hydration when the host is online", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: false,
}),
).toEqual({ kind: "loading", hostName: "Laptop" });
});
it("returns ready when the host is online and the descriptor exists", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
}),
).toEqual({ kind: "ready" });
});
});
@@ -0,0 +1,55 @@
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export type WorkspaceRouteState =
| { kind: "ready" }
| {
kind: "reconnecting";
hostName: string;
connectionStatus: Exclude<HostRuntimeConnectionStatus, "online">;
lastError: string | null;
}
| {
kind: "unreachable";
hostName: string;
connectionStatus: Exclude<HostRuntimeConnectionStatus, "online">;
lastError: string | null;
}
| { kind: "loading"; hostName: string }
| { kind: "missing"; hostName: string };
export function resolveWorkspaceRouteState(input: {
hostName: string;
connectionStatus: HostRuntimeConnectionStatus;
lastError: string | null;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
}): WorkspaceRouteState {
if (input.workspace) {
if (input.connectionStatus === "online") {
return { kind: "ready" };
}
return {
kind: "reconnecting",
hostName: input.hostName,
connectionStatus: input.connectionStatus,
lastError: input.lastError,
};
}
if (input.connectionStatus === "online") {
if (input.hasHydratedWorkspaces) {
return { kind: "missing", hostName: input.hostName };
}
return { kind: "loading", hostName: input.hostName };
}
return {
kind: "unreachable",
hostName: input.hostName,
connectionStatus: input.connectionStatus,
lastError: input.lastError,
};
}
@@ -1,8 +1,18 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
@@ -67,7 +77,13 @@ import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
@@ -106,9 +122,16 @@ import {
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
resolveWorkspaceHeaderRenderState,
shouldRenderMissingWorkspaceDescriptor,
type WorkspaceHeaderCheckoutState,
} from "@/screens/workspace/workspace-header-source";
import {
resolveWorkspaceRouteState,
type WorkspaceRouteState,
} from "@/screens/workspace/workspace-route-state";
import {
renderWorkspaceReconnectIndicator,
renderWorkspaceRouteGate,
} from "@/screens/workspace/workspace-route-state-views";
import {
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
@@ -132,6 +155,7 @@ import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
@@ -166,6 +190,7 @@ const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedCo
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
const GATED_WORKSPACE_HEADER_LEFT = <SidebarMenuToggle />;
interface WorkspaceScreenProps {
serverId: string;
@@ -971,7 +996,6 @@ function parsePaneDirection(actionId: string): PaneDirection | null {
}
interface RenderWorkspaceContentInput {
showMissingWorkspaceDescriptor: boolean;
isMissingWorkspaceExecutionAuthority: boolean;
activeTabDescriptor: WorkspaceTabDescriptor | null;
hasHydratedAgents: boolean;
@@ -987,7 +1011,6 @@ interface RenderWorkspaceContentInput {
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
const {
showMissingWorkspaceDescriptor,
isMissingWorkspaceExecutionAuthority,
activeTabDescriptor,
hasHydratedAgents,
@@ -998,13 +1021,6 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
buildMobilePaneContentModel,
} = input;
if (showMissingWorkspaceDescriptor) {
return (
<View style={styles.emptyState}>
<ThemedActivityIndicator uniProps={mutedColorMapping} />
</View>
);
}
if (isMissingWorkspaceExecutionAuthority) {
return (
<View style={styles.emptyState}>
@@ -1152,6 +1168,170 @@ function removeTerminalFromPayload(terminalId: string) {
};
}
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
const trimmed = host?.label?.trim();
return trimmed ? trimmed : fallback;
}
function useWorkspaceRouteActions(normalizedServerId: string): {
handleRetryHost: () => void;
handleManageHost: () => void;
handleDismissMissingWorkspace: () => void;
} {
const router = useRouter();
const handleRetryHost = useCallback(() => {
if (!normalizedServerId) {
return;
}
void getHostRuntimeStore().runProbeCycleNow(normalizedServerId);
}, [normalizedServerId]);
const handleManageHost = useCallback(() => {
if (!normalizedServerId) {
return;
}
router.push(buildSettingsHostRoute(normalizedServerId) as Href);
}, [normalizedServerId, router]);
const handleDismissMissingWorkspace = useCallback(() => {
if (router.canGoBack()) {
router.back();
return;
}
if (normalizedServerId) {
router.replace(buildHostRootRoute(normalizedServerId) as Href);
return;
}
router.replace("/" as Href);
}, [normalizedServerId, router]);
return {
handleRetryHost,
handleManageHost,
handleDismissMissingWorkspace,
};
}
function useResolvedWorkspaceRouteState(input: {
serverId: string;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
}): WorkspaceRouteState {
const hosts = useHosts();
const host = useMemo(
() => hosts.find((entry) => entry.serverId === input.serverId) ?? null,
[hosts, input.serverId],
);
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
return useMemo(
() =>
resolveWorkspaceRouteState({
hostName,
connectionStatus: hostSnapshot?.connectionStatus ?? "connecting",
lastError: hostSnapshot?.lastError ?? null,
workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
}),
[
hostName,
hostSnapshot?.connectionStatus,
hostSnapshot?.lastError,
input.workspace,
input.hasHydratedWorkspaces,
],
);
}
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
return (
<>
<ScreenHeader left={GATED_WORKSPACE_HEADER_LEFT} />
<View style={styles.centerContent}>{children}</View>
</>
);
}
function renderWorkspaceScreenGateShell(input: {
gate: ReactNode;
workspaceKey: string | null;
}): ReactElement | null {
if (!input.gate) {
return null;
}
return (
<WorkspaceFocusProvider workspaceKey={input.workspaceKey}>
<View style={containerWithWorkspaceBackgroundStyle}>
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
<WorkspaceScreenGateFrame>{input.gate}</WorkspaceScreenGateFrame>
</View>
</View>
</View>
</WorkspaceFocusProvider>
);
}
function WorkspaceDocumentTitleEffectSlot({
tab,
serverId,
workspaceId,
isRouteFocused,
}: {
tab: WorkspaceTabDescriptor | null;
serverId: string;
workspaceId: string;
isRouteFocused: boolean;
}) {
if (!isRouteFocused || !isWeb || !tab) {
return null;
}
return (
<WorkspaceTabPresentationResolver tab={tab} serverId={serverId} workspaceId={workspaceId}>
{(presentation) => (
<WorkspaceDocumentTitleEffect
label={presentation.label}
titleState={presentation.titleState}
/>
)}
</WorkspaceTabPresentationResolver>
);
}
function shouldShowWorkspaceScreenHeader(input: {
isFocusModeEnabled: boolean;
isMobile: boolean;
}): boolean {
return !input.isFocusModeEnabled || input.isMobile;
}
function shouldShowWorkspaceExplorerSidebar(input: {
isRouteFocused: boolean;
isFocusModeEnabled: boolean;
isMobile: boolean;
}): boolean {
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
function canCreateWorkspaceTerminal(input: {
isRouteFocused: boolean;
client: unknown;
isConnected: boolean;
workspaceDirectory: string | null;
}): boolean {
return Boolean(
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
function WorkspaceScreenContent({
serverId,
workspaceId,
@@ -1169,12 +1349,11 @@ function WorkspaceScreenContent({
[workspaceId],
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() =>
normalizedServerId && normalizedWorkspaceId
? `${normalizedServerId}:${normalizedWorkspaceId}`
: null,
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
@@ -1202,7 +1381,7 @@ function WorkspaceScreenContent({
paneId?: string;
} | null>(null);
const canCreateTerminalNow = useMemo(
() => Boolean(isRouteFocused && client && isConnected && workspaceDirectory),
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
@@ -1351,7 +1530,7 @@ function WorkspaceScreenContent({
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
const isCheckoutQueryEnabled = useMemo(
() => Boolean(isRouteFocused && client && isConnected && workspaceDirectory),
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
[isRouteFocused, client, isConnected, workspaceDirectory],
);
const checkoutQuery = useQuery({
@@ -1381,6 +1560,11 @@ function WorkspaceScreenContent({
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
);
const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId,
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});
useEffect(() => {
if (!pendingTerminalCreateInput) {
return;
@@ -2602,12 +2786,7 @@ function WorkspaceScreenContent({
},
[buildPaneContentModel],
);
const showMissingWorkspaceDescriptor = shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});
const content = renderWorkspaceContent({
showMissingWorkspaceDescriptor,
isMissingWorkspaceExecutionAuthority,
activeTabDescriptor,
hasHydratedAgents,
@@ -2718,6 +2897,22 @@ function WorkspaceScreenContent({
const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
const menuCopyIcon = MENU_COPY_ICON;
const menuSettingsIcon = MENU_SETTINGS_ICON;
const workspaceScreenGate = renderWorkspaceRouteGate({
state: workspaceRouteState,
actions: {
onRetryHost: handleRetryHost,
onManageHost: handleManageHost,
onDismissMissingWorkspace: handleDismissMissingWorkspace,
},
});
const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({
gate: workspaceScreenGate,
workspaceKey: persistenceKey,
});
const reconnectBanner = renderWorkspaceReconnectIndicator({
state: workspaceRouteState,
onRetryHost: handleRetryHost,
});
const headerRight = useMemo(
() => (
@@ -2855,16 +3050,12 @@ function WorkspaceScreenContent({
],
);
const documentTitleEffectTab = useMemo(
() => (isRouteFocused && isWeb ? activeTabDescriptor : null),
[isRouteFocused, activeTabDescriptor],
);
const showScreenHeader = useMemo(
() => !isFocusModeEnabled || isMobile,
() => shouldShowWorkspaceScreenHeader({ isFocusModeEnabled, isMobile }),
[isFocusModeEnabled, isMobile],
);
const showExplorerSidebar = useMemo(
() => isRouteFocused && (!isFocusModeEnabled || isMobile),
() => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }),
[isRouteFocused, isFocusModeEnabled, isMobile],
);
const createTerminalDisabled = useMemo(
@@ -2951,134 +3142,129 @@ function WorkspaceScreenContent({
]);
return (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<View style={containerStyle}>
{documentTitleEffectTab ? (
<WorkspaceTabPresentationResolver
tab={documentTitleEffectTab}
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => (
<WorkspaceDocumentTitleEffect
label={presentation.label}
titleState={presentation.titleState}
/>
)}
</WorkspaceTabPresentationResolver>
) : null}
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</>
}
right={headerRight}
/>
)}
{isMobile ? (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{reconnectBanner}
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
currentBranchName={currentBranchName}
isGitCheckout={isGitCheckout}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</>
}
right={headerRight}
/>
)}
</View>
</View>
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
{isMobile ? (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onReloadAgent={handleReloadAgent}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
disableCreateTerminal={createTerminalMutation.isPending}
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={noop}
onSplitDown={noop}
showPaneSplitActions={false}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
</View>
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
</View>
</View>
</View>
</WorkspaceFocusProvider>
</WorkspaceFocusProvider>
)
);
}
@@ -265,6 +265,7 @@ export function activateNavigationWorkspaceSelection(
next: ActiveWorkspaceSelection,
options: ActivateWorkspaceSelectionOptions = {},
) {
setLastWorkspaceRouteSelection(next);
writeBrowserWorkspaceUrl(next, options);
emitIfChanged(next);
}