Files
paseo/packages/app/src/screens/workspace/workspace-screen.tsx
T
Mohamed BoudraandChristoph Leiter c0daf8e066 feat(app): add running status ring and lighten status dots
Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-08-09 11:33:58 +02:00

4203 lines
130 KiB
TypeScript

import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ComponentProps,
type ReactNode,
} from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { DiffStat } from "@/components/diff-stat";
import {
ChevronDown,
Copy,
Ellipsis,
Globe,
Import as ImportIcon,
PanelRight,
Settings,
SquarePen,
SquareTerminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostBadge } from "@/hosts/host-badge";
import { useHostBadges } from "@/hosts/use-host-badges";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
FloatingPanelPortalHost,
FloatingPanelPortalHostNameProvider,
} from "@/components/ui/floating-panel-portal";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { WindowChromeRegion } from "@/utils/desktop-window";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
collectAllTabs,
getFocusedBrowserId,
type WorkspaceLayout,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
type WorkspaceTabTarget,
} from "@/workspace-tabs/model";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import { selectVisibleAgentIds } from "./visible-agent-ids";
import {
getHostRuntimeStore,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { removeResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { createWorkspaceBrowser, useBrowserStore } from "@/desktop/browser/store";
import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
WorkspaceTabOptionRow,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import {
useWorkspaceTabRename,
WorkspaceTabRenameModal,
} from "@/screens/workspace/use-workspace-tab-rename";
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
import {
WorkspaceDesktopTabsRow,
type WorkspaceDesktopTabRowItem,
} from "@/screens/workspace/workspace-desktop-tabs-row";
import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
resolveWorkspaceHeaderRenderState,
type WorkspaceHeaderCheckoutState,
} from "@/screens/workspace/workspace-header-source";
import {
resolveWorkspaceRouteState,
type WorkspaceRouteState,
} from "@/screens/workspace/workspace-route-state";
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recovery";
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
} from "@/workspace-tabs/agent-visibility";
import {
deriveWorkspacePaneState,
resolveSideFileOpenPlacement,
} from "@/screens/workspace/workspace-pane-state";
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { WorkspaceFocusProvider } from "@/workspace/focus";
import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed";
import {
buildBulkCloseConfirmationMessage,
type BulkCloseConfirmationLabels,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { resolveCloseAgentTabPolicy } from "@/subagents";
import {
getPanelInstanceAttributes,
useModifiedPanelTabIds,
} from "@/panels/panel-instance-attributes";
import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import {
buildHostRootRoute,
buildSettingsHostRoute,
buildSettingsHostSectionRoute,
} from "@/utils/host-routes";
import {
useWorkspaceTerminals,
type TerminalProfileInput,
} from "@/screens/workspace/terminals/use-workspace-terminals";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import {
resolveTerminalProfileLaunch,
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { getProviderIcon } from "@/components/provider-icons";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
function getWorkspaceScripts(
workspaceDescriptor: WorkspaceDescriptor | null | undefined,
): WorkspaceDescriptor["scripts"] {
return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS;
}
interface WorkspaceFileLocationFields {
path: string | null;
lineStart?: number;
lineEnd?: number;
}
function getWorkspaceFileLocationFields(
tab: WorkspaceTabDescriptor | null,
): WorkspaceFileLocationFields {
const target = tab?.target;
if (target?.kind !== "file") {
return { path: null };
}
return { path: target.path, lineStart: target.lineStart, lineEnd: target.lineEnd };
}
function buildWorkspaceFileLocation(
fields: WorkspaceFileLocationFields,
): WorkspaceFileLocation | null {
if (fields.path === null) {
return null;
}
return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd };
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCopy = withUnistyles(Copy);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedImport = withUnistyles(ImportIcon);
const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
interface DynamicProviderIconProps {
iconKey: string;
size: number;
color?: string;
}
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedDynamicProviderIcon = withUnistyles(DynamicProviderIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const extraMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_IMPORT_ICON = <ThemedImport 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;
workspaceId: string;
isRouteFocused?: boolean;
recoveryRequested?: boolean;
recoveryAgentId?: string | null;
}
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
isRouteFocused: boolean;
recoveryRequested: boolean;
recoveryAgentId: string | null;
};
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function useSyncWorkspaceActiveBrowser(input: {
workspaceLayout: WorkspaceLayout | null;
isRouteFocused: boolean;
workspaceId: string;
}) {
const focusedBrowserId = useMemo(
() => getFocusedBrowserId(input.workspaceLayout),
[input.workspaceLayout],
);
useEffect(() => {
if (!getIsElectron()) {
return;
}
void getDesktopHost()?.browser?.setWorkspaceActiveBrowser?.({
workspaceId: input.workspaceId,
browserId: focusedBrowserId,
});
}, [focusedBrowserId, input.workspaceId]);
}
function getFallbackTabOptionLabel(
tab: WorkspaceTabDescriptor,
labels: {
newAgent: string;
setup: string;
terminal: string;
browser: string;
agent: string;
changes: string;
},
): string {
if (tab.target.kind === "draft") {
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return labels.setup;
}
if (tab.target.kind === "terminal") {
return labels.terminal;
}
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
if (tab.target.kind === "working_diff") {
return labels.changes;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
return labels.agent;
}
function getFallbackTabOptionDescription(
tab: WorkspaceTabDescriptor,
labels: {
newAgent: string;
workspaceSetup: string;
agent: string;
terminal: string;
browser: string;
changes: string;
},
): string {
if (tab.target.kind === "draft") {
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return labels.workspaceSetup;
}
if (tab.target.kind === "agent") {
return labels.agent;
}
if (tab.target.kind === "terminal") {
return labels.terminal;
}
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "provider_subagent") {
return labels.agent;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
if (tab.target.kind === "working_diff") {
return labels.changes;
}
return tab.target.path;
}
interface MobileWorkspaceTabSwitcherProps {
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
activeTab: WorkspaceTabDescriptor | null;
tabSwitcherOptions: ComboboxOption[];
tabByKey: Map<string, WorkspaceTabDescriptor>;
normalizedServerId: string;
normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}
function MobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
backdrop,
}: {
activeTab: WorkspaceTabDescriptor | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
backdrop: SurfaceBackdrop;
}) {
if (!activeTab) {
return null;
}
return (
<ResolvedMobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
backdrop={backdrop}
/>
);
}
function ResolvedMobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
backdrop,
}: {
activeTab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
backdrop: SurfaceBackdrop;
}) {
const { t } = useTranslation();
return (
<WorkspaceTabPresentationResolver
tab={activeTab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => (
<>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
<WorkspaceTabIcon presentation={presentation} active backdrop={backdrop} />
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</>
)}
</WorkspaceTabPresentationResolver>
);
}
function WorkspaceDocumentTitleEffect({
label,
titleState,
}: {
label: string;
titleState: "ready" | "loading";
}) {
const { t } = useTranslation();
useEffect(() => {
if (isNative || typeof document === "undefined") {
return;
}
const resolvedLabel = label.trim();
document.title =
titleState === "loading"
? t("workspace.tabs.loading")
: resolvedLabel || t("workspace.tabs.fallback.workspace");
}, [label, titleState, t]);
return null;
}
function noop() {}
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
}
function MobileWorkspaceTabOption({
tab,
tabIndex,
tabCount,
normalizedServerId,
normalizedWorkspaceId,
selected,
active,
onPress,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: {
tab: WorkspaceTabDescriptor;
tabIndex: number;
tabCount: number;
normalizedServerId: string;
normalizedWorkspaceId: string;
selected: boolean;
active: boolean;
onPress: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { t } = useTranslation();
const tabMenuLabels = useMemo<WorkspaceTabMenuLabels>(
() => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"),
closeLeft: t("workspace.tabs.menu.closeLeft"),
closeRight: t("workspace.tabs.menu.closeRight"),
closeOthers: t("workspace.tabs.menu.closeOthers"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"),
close: t("workspace.tabs.menu.close"),
}),
[t],
);
const menuTestIDBase = `workspace-tab-menu-${buildDeterministicWorkspaceTabId(tab.target)}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
tabCount,
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs,
labels: tabMenuLabels,
});
const fallbackLabels = useMemo(
() => ({
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}),
[t],
);
const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels);
const trailingAccessory = useMemo(
() => (
<MobileTabTrailingAccessory
menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel}
menuEntries={menuEntries}
/>
),
[menuTestIDBase, fallbackLabel, menuEntries],
);
const renderPresentation = useCallback(
(presentation: WorkspaceTabPresentation) => (
<WorkspaceTabOptionRow
presentation={presentation}
selected={selected}
active={active}
onPress={onPress}
trailingAccessory={trailingAccessory}
/>
),
[selected, active, onPress, trailingAccessory],
);
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{renderPresentation}
</WorkspaceTabPresentationResolver>
);
}
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
tabs,
activeTabKey,
activeTab,
tabSwitcherOptions,
tabByKey,
normalizedServerId,
normalizedWorkspaceId,
onSelectSwitcherTab,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => {
const map = new Map<string, number>();
tabs.forEach((tab, index) => {
map.set(tab.key, index);
});
return map;
}, [tabs]);
const handleOpenSwitcher = useCallback(() => {
Keyboard.dismiss();
setIsOpen(true);
}, []);
const renderTabOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
},
[
tabByKey,
tabIndexByKey,
tabs.length,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
],
);
return (
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
<Pressable
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.switcher.trigger", { count: tabs.length })}
style={switcherTriggerStyle}
onPress={handleOpenSwitcher}
>
{({ pressed }) => (
<>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
backdrop={pressed ? "surface1" : "surface0"}
/>
</View>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</>
)}
</Pressable>
<Combobox
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title={t("workspace.tabs.switcher.title")}
searchPlaceholder={t("workspace.tabs.switcher.searchPlaceholder")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
renderOption={renderTabOption}
/>
</View>
);
});
interface MobileMountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string | null;
buildPaneContentModel: (input: {
paneId: string | null;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
tabDescriptor,
isVisible,
isWorkspaceFocused,
isPaneFocused,
paneId,
buildPaneContentModel,
}: MobileMountedTabSlotProps) {
const content = useMemo(
() =>
buildPaneContentModel({
paneId,
tab: tabDescriptor,
}),
[buildPaneContentModel, paneId, tabDescriptor],
);
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<RetainedPanel active={isVisible} style={styles.mobileMountedTabSlot}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</RetainedPanel>
</RenderProfile>
);
});
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
const next = new Map<string, WorkspaceTabDescriptor>();
for (const tabDescriptor of tabDescriptors) {
const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
if (
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
continue;
}
next.set(tabDescriptor.tabId, tabDescriptor);
}
return next;
}, [tabDescriptors]);
useEffect(() => {
cacheRef.current = tabDescriptorMap;
}, [tabDescriptorMap]);
return tabDescriptorMap;
}
export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId,
workspaceId,
isRouteFocused,
recoveryRequested,
recoveryAgentId,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
useEffect(() => {
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
return () => {
traceInstant("paseo.workspace.unmount", { serverId, workspaceId });
};
}, [serverId, workspaceId]);
return (
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
recoveryRequested={recoveryRequested ?? false}
recoveryAgentId={recoveryAgentId ?? null}
/>
);
});
interface UseCloseTabsResult {
closingTabIds: Set<string>;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
}
function useCloseTabs(): UseCloseTabsResult {
const pendingRef = useRef(new Set<string>());
const [closingTabIds, setClosingTabIds] = useState<Set<string>>(EMPTY_SET);
const closeTab = useCallback(async (tabId: string, action: () => Promise<void>) => {
const normalized = tabId.trim();
if (!normalized || pendingRef.current.has(normalized)) {
return;
}
pendingRef.current.add(normalized);
setClosingTabIds(new Set(pendingRef.current));
try {
await action();
} finally {
pendingRef.current.delete(normalized);
setClosingTabIds(new Set(pendingRef.current));
}
}, []);
return { closingTabIds, closeTab };
}
interface WorkspaceHeaderMenuProps {
normalizedServerId: string;
currentBranchName: string | null;
showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
copyPathDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuImportIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
}
interface HeaderMenuProfileItemProps {
profile: { id: string; name: string; command: string; args?: string[]; icon?: string };
disabled: boolean;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
}
function HeaderMenuProfileItem({
profile,
disabled,
onCreateTerminalWithProfile,
}: HeaderMenuProfileItemProps) {
const handleSelect = useCallback(() => {
onCreateTerminalWithProfile(resolveTerminalProfileLaunch(profile, ""));
}, [onCreateTerminalWithProfile, profile]);
const icon = getTerminalProfileIcon(profile);
const leading = useMemo(() => {
if (!icon) {
return (
<View style={styles.headerMenuProfileIconWrapper}>
<ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />
</View>
);
}
return (
<View style={styles.headerMenuProfileIconWrapper}>
<ThemedDynamicProviderIcon iconKey={icon} size={16} uniProps={mutedColorMapping} />
</View>
);
}, [icon]);
return (
<DropdownMenuItem leading={leading} disabled={disabled} onSelect={handleSelect}>
{profile.name}
</DropdownMenuItem>
);
}
/**
* The compact header buttons draw at 32pt, under the 44pt touch minimum, and sit flush against
* each other — so the slop can only grow vertically. Widening it would put two buttons' slop over
* the same pixels, which is a worse miss than a small target.
*/
const COMPACT_HEADER_BUTTON_HIT_SLOP = { top: 8, bottom: 8 } as const;
function WorkspaceHeaderMenuTriggerIcon({ hovered, open }: { hovered: boolean; open: boolean }) {
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
return <ThemedEllipsis size={16} uniProps={colorMapping} />;
}
function WorkspaceHeaderMenu({
normalizedServerId,
currentBranchName,
showWorkspaceSetup,
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
copyPathDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuImportIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
}: WorkspaceHeaderMenuProps) {
const { t } = useTranslation();
const router = useRouter();
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
[config?.terminalProfiles],
);
const handleEditProfiles = useCallback(() => {
router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href);
}, [normalizedServerId, router]);
const renderTriggerIcon = useCallback(
({ hovered, open }: { hovered: boolean; open: boolean }) => (
<WorkspaceHeaderMenuTriggerIcon hovered={hovered} open={open} />
),
[],
);
return (
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={isMobile ? styles.compactHeaderActionButton : styles.headerActionButton}
hitSlop={isMobile ? COMPACT_HEADER_BUTTON_HIT_SLOP : undefined}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
>
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
width={220}
testID="workspace-header-menu"
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={menuNewAgentIcon}
onSelect={onCreateDraftTab}
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
leading={menuNewBrowserIcon}
onSelect={onCreateBrowser}
>
{t("workspace.header.actions.newBrowser")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="workspace-header-import-agent"
leading={menuImportIcon}
disabled={importAgentDisabled}
onSelect={onOpenImportSheet}
>
{t("workspace.header.actions.importSession")}
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={menuCopyIcon}
disabled={copyPathDisabled}
onSelect={onCopyWorkspacePath}
>
{t("workspace.header.actions.copyPath")}
</DropdownMenuItem>
{currentBranchName ? (
<DropdownMenuItem
testID="workspace-header-copy-branch-name"
leading={menuCopyIcon}
onSelect={onCopyBranchName}
>
{t("workspace.header.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{showWorkspaceSetup ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-show-setup"
leading={menuSettingsIcon}
onSelect={onOpenSetupTab}
>
{t("workspace.header.actions.showSetup")}
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={menuNewTerminalIcon}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-edit-terminal-profiles"
onSelect={handleEditProfiles}
>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* Which project the workspace belongs to, and which machine it runs on.
*
* Compact gets both, on their own line under the workspace name: this header is the only thing on
* screen that says where the workspace lives, because the sidebar that normally carries the host
* badge is closed. It still follows the host's own badge setting, so a purely local setup stays
* quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit
* side by side, and kept on compact, where the line exists for the host anyway.
*/
function WorkspaceHeaderProjectRow({
subtitle,
isSubtitleDistinct,
serverId,
}: {
subtitle: string;
isSubtitleDistinct: boolean;
serverId: string;
}) {
const isCompact = useIsCompactFormFactor();
const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null;
const showProject = isSubtitleDistinct || isCompact;
if (!showProject && !hostBadge) {
return null;
}
return (
<View style={styles.headerProjectRow}>
{showProject ? (
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.headerProjectSeparator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
interface WorkspaceHeaderTitleBarProps {
isLoading: boolean;
title: string;
subtitle: string;
isSubtitleDistinct: boolean;
currentBranchName: string | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceScripts: WorkspaceDescriptor["scripts"];
liveTerminalIds: string[];
showWorkspaceSetup: boolean;
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
copyPathDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuImportIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
onScriptTerminalStarted: (terminalId: string) => void;
onViewScriptTerminal: (terminalId: string) => void;
onOpenUrlInBrowserTab: (url: string) => void;
}
function WorkspaceHeaderTitleBar({
isLoading,
title,
subtitle,
isSubtitleDistinct,
currentBranchName,
normalizedServerId,
normalizedWorkspaceId,
workspaceScripts,
liveTerminalIds,
showWorkspaceSetup,
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
copyPathDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuImportIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
onScriptTerminalStarted,
onViewScriptTerminal,
onOpenUrlInBrowserTab,
}: WorkspaceHeaderTitleBarProps) {
return (
<View style={styles.headerTitleContainer}>
{isLoading ? (
<View style={styles.headerTitleTextGroup}>
<View style={styles.headerTitleSkeleton} />
</View>
) : (
<View style={styles.headerTitleTextGroup}>
<ScreenTitle testID="workspace-header-title" style={styles.headerTitle}>
{title}
</ScreenTitle>
<WorkspaceHeaderProjectRow
subtitle={subtitle}
isSubtitleDistinct={isSubtitleDistinct}
serverId={normalizedServerId}
/>
</View>
)}
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenu
normalizedServerId={normalizedServerId}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon}
menuImportIcon={menuImportIcon}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
onCreateBrowser={onCreateBrowser}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
{isMobile && workspaceScripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceScripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={onScriptTerminalStarted}
onViewTerminal={onViewScriptTerminal}
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
hideLabels
presentation="ghost"
/>
) : null}
</View>
</View>
);
}
type PaneDirection = "left" | "right" | "up" | "down";
function parsePaneDirection(actionId: string): PaneDirection | null {
const direction = actionId.split(".").pop();
if (direction === "left" || direction === "right" || direction === "up" || direction === "down") {
return direction;
}
return null;
}
interface RenderWorkspaceContentInput {
isMissingWorkspaceDirectory: boolean;
activeTabDescriptor: WorkspaceTabDescriptor | null;
hasHydratedAgents: boolean;
mountedFocusedPaneTabIds: string[];
focusedPaneTabDescriptorMap: Map<string, WorkspaceTabDescriptor>;
isRouteFocused: boolean;
focusedPaneId: string | null;
buildMobilePaneContentModel: (input: {
paneId: string | null;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
const {
isMissingWorkspaceDirectory,
activeTabDescriptor,
hasHydratedAgents,
mountedFocusedPaneTabIds,
focusedPaneTabDescriptorMap,
isRouteFocused,
focusedPaneId,
buildMobilePaneContentModel,
} = input;
if (isMissingWorkspaceDirectory) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
Workspace directory is missing. Reload workspace data before opening tabs.
</Text>
</View>
);
}
if (!activeTabDescriptor && !hasHydratedAgents) {
return (
<View style={styles.emptyState}>
<ThemedLoadingSpinner uniProps={mutedColorMapping} />
</View>
);
}
if (!activeTabDescriptor) {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
No tabs are available yet. Use New tab to create an agent or terminal.
</Text>
</View>
);
}
return mountedFocusedPaneTabIds.map((tabId) => {
const tabDescriptor = focusedPaneTabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MobileMountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId}
isWorkspaceFocused={isRouteFocused}
isPaneFocused={tabId === activeTabDescriptor.tabId}
paneId={focusedPaneId}
buildPaneContentModel={buildMobilePaneContentModel}
/>
);
});
}
interface WorkspaceHeaderFields {
isWorkspaceHeaderLoading: boolean;
workspaceHeaderTitle: string;
workspaceHeaderSubtitle: string;
isWorkspaceHeaderSubtitleDistinct: boolean;
isGitCheckout: boolean;
currentBranchName: string | null;
}
function buildWorkspaceHeaderCheckoutState(input: {
isCheckoutStatusLoading: boolean;
isError: boolean;
data: CheckoutStatusPayload | undefined;
}): WorkspaceHeaderCheckoutState {
if (input.isCheckoutStatusLoading) {
return { kind: "pending" };
}
if (input.isError || !input.data) {
return { kind: "error" };
}
return {
kind: "ready",
checkout: {
isGit: input.data.isGit,
currentBranch: input.data.currentBranch,
},
};
}
function deriveWorkspaceHeaderFields(input: {
workspace: WorkspaceDescriptor | null;
checkoutState: WorkspaceHeaderCheckoutState;
}): WorkspaceHeaderFields {
const renderState = resolveWorkspaceHeaderRenderState(input);
if (renderState.kind !== "ready") {
return {
isWorkspaceHeaderLoading: true,
workspaceHeaderTitle: "",
workspaceHeaderSubtitle: "",
isWorkspaceHeaderSubtitleDistinct: false,
isGitCheckout: false,
currentBranchName: null,
};
}
return {
isWorkspaceHeaderLoading: false,
workspaceHeaderTitle: renderState.title,
workspaceHeaderSubtitle: renderState.subtitle,
isWorkspaceHeaderSubtitleDistinct: renderState.isSubtitleDistinct,
isGitCheckout: renderState.isGitCheckout,
currentBranchName: renderState.currentBranchName,
};
}
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;
recovery: WorkspaceRecoveryModel;
}): 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,
recovery: input.recovery,
}),
[
hostName,
hostSnapshot?.connectionStatus,
hostSnapshot?.lastError,
input.workspace,
input.hasHydratedWorkspaces,
input.recovery,
],
);
}
function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null,
recoveryRequested: boolean,
): boolean {
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
}
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={styles.container}>
<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.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
}
interface WorkspaceChromeRowProps extends Omit<
ComponentProps<typeof ExplorerSidebar>,
"workspaceRoot"
> {
children: ReactNode;
explorerOpen: boolean;
portalHostName: string;
showExplorerSidebar: boolean;
workspaceRoot: string | null;
}
function WorkspaceChromeRow({
children,
explorerOpen,
portalHostName,
showExplorerSidebar,
workspaceRoot,
...explorerProps
}: WorkspaceChromeRowProps) {
const explorerRendered = showExplorerSidebar && explorerOpen && workspaceRoot !== null;
return (
<View style={styles.threePaneRow}>
<WindowChromeRegion corners={explorerRendered ? "top-left" : "both"}>
<FloatingPanelPortalHostNameProvider hostName={portalHostName}>
{children}
</FloatingPanelPortalHostNameProvider>
</WindowChromeRegion>
<FloatingPanelPortalHost name={portalHostName} />
{showExplorerSidebar && workspaceRoot ? (
<WindowChromeRegion corners="top-right">
<ExplorerSidebar {...explorerProps} workspaceRoot={workspaceRoot} />
</WindowChromeRegion>
) : null}
</View>
);
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
interface WorkspaceTerminalTabActionsInput {
persistenceKey: string | null;
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
labels: {
workspacePathUnavailable: string;
terminalQueued: string;
};
toast: {
error: (message: string) => void;
show: (message: string) => void;
};
}
interface WorkspaceTerminalTabActions {
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
handleScriptTerminalSelected: (terminalId: string) => void;
handleWorkspacePathUnavailable: () => void;
handleTerminalCreateQueued: () => void;
handleTerminalCreateFailed: (reason: string) => void;
}
function useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
labels,
toast,
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
const handleTerminalCreated = useCallback(
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
if (!persistenceKey) {
return;
}
if (paneId) {
focusWorkspacePane(persistenceKey, paneId);
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleScriptTerminalSelected = useCallback(
(terminalId: string) => {
if (!persistenceKey) {
return;
}
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleWorkspacePathUnavailable = useCallback(() => {
toast.error(labels.workspacePathUnavailable);
}, [labels.workspacePathUnavailable, toast]);
const handleTerminalCreateQueued = useCallback(() => {
toast.show(labels.terminalQueued);
}, [labels.terminalQueued, toast]);
const handleTerminalCreateFailed = useCallback(
(reason: string) => {
toast.error(reason);
},
[toast],
);
return {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
handleTerminalCreateFailed,
};
}
function WorkspaceScreenContent({
serverId,
workspaceId,
isRouteFocused,
recoveryRequested,
recoveryAgentId,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const normalizedServerId = useMemo(() => trimNonEmpty(decodeSegment(serverId)) ?? "", [serverId]);
const normalizedWorkspaceId = useMemo(
() => resolveWorkspaceRouteId({ routeWorkspaceId: workspaceId }) ?? "",
[workspaceId],
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
scopeKey: workspaceTerminalScopeKey,
});
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const supportsProvidersSnapshot = useSessionStore(
(state) => state.sessions[normalizedServerId]?.serverInfo?.features?.providersSnapshot === true,
);
const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null;
const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory;
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
const openImportSheet = useCallback(() => {
setIsImportSheetVisible(true);
}, []);
const closeImportSheet = useCallback(() => {
setIsImportSheetVisible(false);
}, []);
useEffect(() => {
if (
!isRouteFocused ||
!isConnected ||
!client ||
!workspaceDirectory ||
!supportsProvidersSnapshot
) {
return;
}
prefetchProvidersSnapshot(normalizedServerId, client, { cwd: workspaceDirectory });
}, [
client,
isConnected,
isRouteFocused,
normalizedServerId,
supportsProvidersSnapshot,
workspaceDirectory,
]);
const persistenceKey = useMemo(
() =>
buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const openWorkspaceChildTabFocused = useWorkspaceLayoutStore(
(state) => state.openChildTabFocused,
);
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
// requests separate so clicking an unchanged path:line can still recenter the pane.
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
Record<string, number>
>({});
const requestFileNavigation = useCallback((tabId: string) => {
setFileNavigationRevisionByTabId((current) => ({
...current,
[tabId]: (current[tabId] ?? 0) + 1,
}));
}, []);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
agentId: recoveryAgentId,
enabled: shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces,
workspaceDescriptor,
recoveryRequested,
),
});
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
(state) =>
deriveWorkspaceAgentVisibility({
sessionAgents: state.sessions[normalizedServerId]?.agents,
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
workspaceId: normalizedWorkspaceId,
}),
workspaceAgentVisibilityEqual,
);
const {
handleTerminalCreated,
handleScriptTerminalSelected,
handleWorkspacePathUnavailable,
handleTerminalCreateQueued,
handleTerminalCreateFailed,
} = useWorkspaceTerminalTabActions({
persistenceKey,
focusWorkspacePane,
openWorkspaceTabFocused,
labels: {
workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"),
terminalQueued: t("workspace.header.toasts.terminalQueued"),
},
toast,
});
const queryClient = useQueryClient();
const {
createMutation: createTerminalMutation,
createTerminal,
handleScriptTerminalStarted,
handleViewScriptTerminal,
invalidateTerminals,
killMutation: killTerminalMutation,
knownTerminalIds,
liveTerminalIds,
pendingCreateInput: pendingTerminalCreateInput,
query: terminalsQuery,
queryKey: terminalsQueryKey,
removeTerminalFromCache,
standaloneTerminalIds,
terminals,
} = useWorkspaceTerminals({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
workspaceScripts,
hasHydratedWorkspaces,
isMissingWorkspaceDirectory,
onTerminalCreated: handleTerminalCreated,
onScriptTerminalSelected: handleScriptTerminalSelected,
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
onTerminalCreateQueued: handleTerminalCreateQueued,
onTerminalCreateFailed: handleTerminalCreateFailed,
});
const { archiveAgent } = useArchiveAgent();
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
client,
isConnected,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
});
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
);
const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId,
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
recovery: workspaceRecovery.state,
});
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading,
isError: checkoutQuery.isError,
data: checkoutQuery.data,
});
const {
isWorkspaceHeaderLoading,
workspaceHeaderTitle,
workspaceHeaderSubtitle,
isWorkspaceHeaderSubtitleDistinct,
isGitCheckout,
currentBranchName,
} = deriveWorkspaceHeaderFields({
workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState,
});
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
if (!normalizedServerId || !workspaceDirectory) {
return null;
}
return {
serverId: normalizedServerId,
cwd: workspaceDirectory,
isGit: isGitCheckout,
};
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
const handleToggleExplorer = useCallback(() => {
if (!activeExplorerCheckout) {
return;
}
toggleFileExplorerForCheckout({
isCompact: isMobile,
checkout: activeExplorerCheckout,
});
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const hasDiffStat = useMemo(() => Boolean(workspaceDescriptor?.diffStat), [workspaceDescriptor]);
const explorerToggleStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.sourceControlButton,
hasDiffStat && styles.sourceControlButtonWithStats,
(Boolean(hovered) || Boolean(pressed) || isExplorerOpen) && styles.sourceControlButtonHovered,
],
[hasDiffStat, isExplorerOpen],
);
const explorerToggleAccessibilityState = useMemo(
() => ({ expanded: isExplorerOpen }),
[isExplorerOpen],
);
useEffect(() => {
if (!isRouteFocused || isWeb || !isExplorerOpen) {
return;
}
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
if (isExplorerOpen) {
showMobileAgent();
return true;
}
return false;
});
return () => handler.remove();
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
);
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null,
);
const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus);
const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot);
const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
);
useSyncWorkspaceActiveBrowser({
workspaceLayout,
isRouteFocused,
workspaceId: normalizedWorkspaceId,
});
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
(state) => state.openTabInBackground,
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
const _pinnedAgentIds = useWorkspaceLayoutStore((state) =>
persistenceKey
? (state.pinnedAgentIdsByWorkspace[persistenceKey] ?? EMPTY_PINNED_AGENT_IDS)
: EMPTY_PINNED_AGENT_IDS,
);
const _hiddenAgentIds = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.hiddenAgentIdsByWorkspace[persistenceKey] ?? EMPTY_SET) : EMPTY_SET,
);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const { closingTabIds, closeTab } = useCloseTabs();
const { onLayout: onHeaderLayout, isBelow: showCompactButtonLabels } =
useContainerWidthBelow(700);
const closeWorkspaceTabWithCleanup = useCallback(
function closeWorkspaceTabWithCleanup(input: {
tabId: string;
target?: WorkspaceTabTarget | null;
}) {
const normalizedTabId = trimNonEmpty(input.tabId);
if (!normalizedTabId || !persistenceKey) {
return;
}
if (input.target?.kind === "agent") {
unpinWorkspaceAgent(persistenceKey, input.target.agentId);
hideWorkspaceAgent(persistenceKey, input.target.agentId);
}
if (input.target?.kind === "browser") {
const { browserId } = input.target;
useBrowserStore.getState().removeBrowser(browserId);
removeResidentBrowserWebview(browserId);
void getDesktopHost()?.browser?.unregisterWorkspaceBrowser?.(browserId);
}
closeWorkspaceTab(persistenceKey, normalizedTabId);
},
[closeWorkspaceTab, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent],
);
const focusedPaneTabState = useMemo(
() =>
deriveWorkspacePaneState({
layout: workspaceLayout,
tabs: uiTabs,
}),
[uiTabs, workspaceLayout],
);
const viewedTimelineSync = useSessionStore(
(state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null,
);
const visibleAgentIds = useMemo(
() =>
selectVisibleAgentIds({
layout: workspaceLayout,
tabs: uiTabs,
routeFocused: isRouteFocused,
focusedPaneOnly: isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(),
}),
[isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout],
);
useLayoutEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
}
viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, visibleAgentIds);
}, [persistenceKey, viewedTimelineSync, visibleAgentIds]);
useEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
}
return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []);
}, [persistenceKey, viewedTimelineSync]);
const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const focusedPaneAgentId = useMemo(() => {
const target = focusedPaneTabState.activeTab?.descriptor.target;
if (target?.kind !== "agent") {
return null;
}
return target.agentId;
}, [focusedPaneTabState.activeTab]);
const focusedPaneTerminalId = useMemo(() => {
const target = focusedPaneTabState.activeTab?.descriptor.target;
if (target?.kind !== "terminal") {
return null;
}
return target.terminalId;
}, [focusedPaneTabState.activeTab]);
useEffect(() => {
if (!isRouteFocused) {
return;
}
setFocusedAgentId(normalizedServerId, focusedPaneAgentId);
setFocusedTerminalId(normalizedServerId, focusedPaneTerminalId);
}, [
focusedPaneAgentId,
focusedPaneTerminalId,
isRouteFocused,
normalizedServerId,
setFocusedAgentId,
setFocusedTerminalId,
]);
useEffect(() => {
if (!isRouteFocused) {
return;
}
return () => {
setFocusedAgentId(normalizedServerId, null);
setFocusedTerminalId(normalizedServerId, null);
};
}, [isRouteFocused, normalizedServerId, setFocusedAgentId, setFocusedTerminalId]);
const openWorkspaceDraftTab = useCallback(
function openWorkspaceDraftTab(input?: { draftId?: string; focus?: boolean }) {
if (!persistenceKey) {
return null;
}
const target = normalizeWorkspaceTabTarget({
kind: "draft",
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
});
invariant(target?.kind === "draft", "Draft tab target must be valid");
if (input?.focus === false) {
return openWorkspaceTabInBackground(persistenceKey, target);
}
return openWorkspaceTabFocused(persistenceKey, target);
},
[openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey],
);
useEffect(() => {
if (!isRouteFocused) {
return;
}
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
if (tab.target.kind !== "draft") {
return false;
}
const pending = pendingByDraftId[tab.target.draftId];
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
});
reconcileWorkspaceTabs(
persistenceKey,
buildWorkspaceTabSnapshot({
agentVisibility: workspaceAgentVisibility,
agentsHydrated: hasHydratedAgents,
terminalsHydrated: terminalsQuery.isSuccess,
knownTerminalIds,
standaloneTerminalIds,
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
}),
);
}, [
hasHydratedAgents,
hasHydratedWorkspaceLayoutStore,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
pendingByDraftId,
persistenceKey,
reconcileWorkspaceTabs,
knownTerminalIds,
standaloneTerminalIds,
terminalsQuery.isSuccess,
uiTabs,
workspaceAgentVisibility,
]);
const activeTabId = focusedPaneTabState.activeTabId;
const activeTab = focusedPaneTabState.activeTab;
const tabs = useMemo<WorkspaceTabDescriptor[]>(
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
[focusedPaneTabState.tabs],
);
const hasSetupTab = useMemo(
() =>
uiTabs.some(
(tab) => tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId,
),
[normalizedWorkspaceId, uiTabs],
);
const navigateToTabId = useCallback(
function navigateToTabId(tabId: string) {
if (!tabId || !persistenceKey) {
return;
}
focusWorkspaceTab(persistenceKey, tabId);
},
[focusWorkspaceTab, persistenceKey],
);
const handleImportedAgent = useCallback(
(agentId: string) => {
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "agent", agentId });
if (tabId) {
navigateToTabId(tabId);
}
},
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
useEffect(() => {
if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) {
return;
}
ensureWorkspaceSetupStatus({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
client,
});
}, [
client,
ensureWorkspaceSetupStatus,
isRouteFocused,
normalizedServerId,
normalizedWorkspaceId,
]);
useEffect(() => {
if (
!shouldSeedEmptyWorkspaceDraft({
isRouteFocused,
hasPersistenceKey: Boolean(persistenceKey),
hasWorkspaceDirectory: Boolean(workspaceDirectory),
hasHydratedWorkspaceLayoutStore,
hasHydratedAgents,
hasLoadedTerminals: terminalsQuery.isSuccess,
activeAgentCount: workspaceAgentVisibility.activeAgentIds.size,
terminalCount: terminals.length,
tabCount: tabs.length,
})
) {
emptyWorkspaceSeedRef.current = null;
return;
}
const workspaceKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
if (emptyWorkspaceSeedRef.current === workspaceKey) {
return;
}
emptyWorkspaceSeedRef.current = workspaceKey;
openWorkspaceDraftTab();
}, [
normalizedServerId,
normalizedWorkspaceId,
openWorkspaceDraftTab,
persistenceKey,
hasHydratedAgents,
hasHydratedWorkspaceLayoutStore,
isRouteFocused,
terminals.length,
terminalsQuery.isSuccess,
tabs.length,
workspaceDirectory,
workspaceAgentVisibility.activeAgentIds.size,
]);
useEffect(() => {
if (!isRouteFocused) {
return;
}
if (!persistenceKey) {
return;
}
if (!workspaceSetupSnapshot || !showWorkspaceSetup) {
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
autoOpenedSetupTabWorkspaceRef.current = null;
}
return;
}
const snapshotAge = Date.now() - workspaceSetupSnapshot.updatedAt;
const shouldAutoOpen =
workspaceSetupSnapshot.status === "running" ||
snapshotAge <= WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS;
if (!shouldAutoOpen) {
return;
}
if (hasSetupTab) {
autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
return;
}
if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) {
return;
}
const target = normalizeWorkspaceTabTarget({
kind: "setup",
workspaceId: normalizedWorkspaceId,
});
if (!target) {
return;
}
const tabId = openWorkspaceTabInBackground(persistenceKey, target);
if (!tabId) {
return;
}
autoOpenedSetupTabWorkspaceRef.current = persistenceKey;
}, [
hasSetupTab,
isRouteFocused,
normalizedWorkspaceId,
openWorkspaceTabInBackground,
persistenceKey,
showWorkspaceSetup,
workspaceSetupSnapshot,
]);
const handleOpenFileFromExplorer = useCallback(
function handleOpenFileFromExplorer(filePath: string) {
if (!persistenceKey) {
return;
}
const location = normalizeWorkspaceFileLocation({ path: filePath });
if (!location) {
return;
}
const tabId = openWorkspaceTabFocused(persistenceKey, createWorkspaceFileTabTarget(location));
if (tabId) {
navigateToTabId(tabId);
}
},
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const handleOpenFileFromChat = useCallback(
(location: WorkspaceFileLocation, options?: { parentTabId?: string | null }) => {
const normalizedLocation = normalizeWorkspaceFileLocation(location);
if (!normalizedLocation) {
return;
}
if (isMobile) {
showMobileAgent();
}
if (!persistenceKey) {
return;
}
const target = createWorkspaceFileTabTarget(normalizedLocation);
const tabId = options?.parentTabId
? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
if (tabId) {
requestFileNavigation(tabId);
navigateToTabId(tabId);
}
},
[
isMobile,
navigateToTabId,
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
requestFileNavigation,
showMobileAgent,
],
);
const handleOpenFileFromChatInSidePane = useCallback(
(input: {
location: WorkspaceFileLocation;
sourcePaneId?: string;
parentTabId?: string | null;
}) => {
const location = normalizeWorkspaceFileLocation(input.location);
if (!location) {
return;
}
if (!persistenceKey || isMobile || !input.sourcePaneId) {
handleOpenFileFromChat(location, { parentTabId: input.parentTabId });
return;
}
const target: WorkspaceTabTarget = createWorkspaceFileTabTarget(location);
const placement = resolveSideFileOpenPlacement({
layout: workspaceLayout,
sourcePaneId: input.sourcePaneId,
tabs: uiTabs,
target,
});
if (placement.kind === "focus-side-pane") {
focusWorkspacePane(persistenceKey, placement.paneId);
} else if (placement.kind === "split-side-pane") {
splitWorkspacePaneEmpty(persistenceKey, {
targetPaneId: placement.paneId,
position: "right",
});
}
const tabId = input.parentTabId
? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId)
: openWorkspaceTabFocused(persistenceKey, target);
if (tabId) {
requestFileNavigation(tabId);
navigateToTabId(tabId);
}
},
[
handleOpenFileFromChat,
isMobile,
focusWorkspacePane,
navigateToTabId,
openWorkspaceChildTabFocused,
openWorkspaceTabFocused,
persistenceKey,
requestFileNavigation,
splitWorkspacePaneEmpty,
uiTabs,
workspaceLayout,
],
);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
request,
paneId,
parentTabId,
focusPaneBeforeOpen,
}: {
request: WorkspaceFileOpenRequest;
paneId?: string | null;
parentTabId: string;
focusPaneBeforeOpen?: boolean;
}) {
if (focusPaneBeforeOpen && paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId });
});
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
useWorkspaceTabRename({
client,
normalizedServerId,
queryClient,
terminalsData: terminalsQuery.data,
terminalsQueryKey,
});
const tabByKey = useMemo(() => {
const map = new Map<string, WorkspaceTabDescriptor>();
for (const tab of tabs) {
map.set(tab.key, tab);
}
return map;
}, [tabs]);
const allTabDescriptorsById = useMemo(() => {
const map = new Map<string, WorkspaceTabDescriptor>();
for (const tab of uiTabs) {
map.set(tab.tabId, {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
});
}
return map;
}, [uiTabs]);
const bulkCloseConfirmationLabels = useMemo<BulkCloseConfirmationLabels>(
() => ({
all: ({ agents, terminals: terminalCount, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.all", {
agents,
terminals: terminalCount,
tabs: tabCount,
}),
agentsAndTerminals: ({ agents, terminals: terminalCount }) =>
t("workspace.tabs.confirmations.bulk.agentsAndTerminals", {
agents,
terminals: terminalCount,
}),
terminalsAndTabs: ({ terminals: terminalCount, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.terminalsAndTabs", {
terminals: terminalCount,
tabs: tabCount,
}),
agentsAndTabs: ({ agents, tabs: tabCount }) =>
t("workspace.tabs.confirmations.bulk.agentsAndTabs", { agents, tabs: tabCount }),
terminals: ({ terminals: terminalCount }) =>
t("workspace.tabs.confirmations.bulk.terminals", { terminals: terminalCount }),
tabs: ({ tabs: tabCount }) => t("workspace.tabs.confirmations.bulk.tabs", { tabs: tabCount }),
agents: ({ agents }) => t("workspace.tabs.confirmations.bulk.agents", { agents }),
}),
[t],
);
const explorerToggleLabel = isExplorerOpen
? t("workspace.tabs.explorer.close")
: t("workspace.tabs.explorer.open");
const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]);
const tabFallbackLabels = useMemo(
() => ({
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}),
[t],
);
const tabSwitcherOptions = useMemo(
() =>
tabs.map((tab) => ({
id: tab.key,
label: getFallbackTabOptionLabel(tab, tabFallbackLabels),
description: getFallbackTabOptionDescription(tab, tabFallbackLabels),
})),
[tabFallbackLabels, tabs],
);
const handleCreateDraftTab = useCallback(
(input?: { paneId?: string }) => {
if (input?.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId);
}
openWorkspaceDraftTab();
},
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
);
const handleCreateTerminal = useStableEvent(createTerminal);
const handleCreateTerminalWithProfile = useCallback(
(profile: TerminalProfileInput) => {
createTerminal({ profile });
},
[createTerminal],
);
const handleCreateBrowserTab = useCallback(
(input?: { paneId?: string }) => {
if (!persistenceKey || !getIsElectron()) {
return;
}
if (input?.paneId) {
focusWorkspacePane(persistenceKey, input.paneId);
}
const { browserId } = createWorkspaceBrowser();
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
},
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
);
const handleOpenUrlInBrowserTab = useCallback(
(url: string) => {
if (!persistenceKey || !getIsElectron()) {
return;
}
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
},
[openWorkspaceTabFocused, persistenceKey],
);
useDesktopBrowserNewTabRequests({
enabled: Boolean(persistenceKey),
workspaceLayout,
openUrl: handleOpenUrlInBrowserTab,
});
const handleSelectSwitcherTab = useCallback(
(key: string) => {
navigateToTabId(key);
},
[navigateToTabId],
);
const handleCreateDraftSplit = useCallback(
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
if (!persistenceKey) {
return;
}
const paneId = splitWorkspacePaneEmpty(persistenceKey, input);
if (!paneId) {
return;
}
handleCreateDraftTab({ paneId });
},
[handleCreateDraftTab, persistenceKey, splitWorkspacePaneEmpty],
);
const killTerminalAsync = killTerminalMutation.mutateAsync;
const handleCloseTerminalTab = useCallback(
async (input: { tabId: string; terminalId: string }) => {
const { tabId, terminalId } = input;
await closeTab(tabId, async () => {
const confirmed = await confirmDialog({
title: t("workspace.tabs.confirmations.closeTerminalTitle"),
message: t("workspace.tabs.confirmations.closeTerminalMessage"),
confirmLabel: t("workspace.tabs.confirmations.close"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
removeTerminalFromCache(terminalId);
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
}
void killTerminalAsync(terminalId).catch(invalidateTerminals);
});
},
[
closeTab,
closeWorkspaceTabWithCleanup,
invalidateTerminals,
killTerminalAsync,
persistenceKey,
removeTerminalFromCache,
t,
],
);
const handleCloseAgentTab = useCallback(
async (input: { tabId: string; agentId: string }) => {
const { tabId, agentId } = input;
await closeTab(tabId, async () => {
if (!normalizedServerId) {
return;
}
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const closePolicy = resolveCloseAgentTabPolicy(agent);
const isRunning = agent?.status === "running";
if (isRunning && closePolicy.kind === "archive-on-close") {
const confirmed = await confirmDialog({
title: t("workspace.tabs.confirmations.archiveRunningAgentTitle"),
message: t("workspace.tabs.confirmations.archiveRunningAgentMessage"),
confirmLabel: t("workspace.tabs.confirmations.archive"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
}
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
}
if (closePolicy.kind === "layout-only") {
return;
}
// Errors (e.g. timeout) are handled by the mutation's onSettled callback
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
);
const handleClosePassiveTab = useCallback(
function handleClosePassiveTab(input: { tabId: string; target?: WorkspaceTabTarget | null }) {
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
}
},
[closeWorkspaceTabWithCleanup, persistenceKey],
);
const confirmDiscardModifiedTab = useCallback(
async (tabId: string): Promise<boolean> => {
const attributes = getPanelInstanceAttributes({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
if (!attributes.modified) return true;
const resumePendingSave = attributes.suspendPendingSave?.();
const confirmed = await confirmDialog({
title: t("workspace.tabs.confirmations.unsavedTitle"),
message: t("workspace.tabs.confirmations.unsavedMessage"),
confirmLabel: t("workspace.tabs.confirmations.closeWithoutSaving"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) resumePendingSave?.();
return confirmed;
},
[normalizedServerId, normalizedWorkspaceId, t],
);
const handleCloseTabById = useCallback(
async (tabId: string) => {
const tab = allTabDescriptorsById.get(tabId);
if (!tab) {
return;
}
if (!(await confirmDiscardModifiedTab(tabId))) {
return;
}
if (tab.target.kind === "terminal") {
await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId });
return;
}
if (tab.target.kind === "agent") {
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
return;
}
handleClosePassiveTab({ tabId, target: tab.target });
},
[
allTabDescriptorsById,
confirmDiscardModifiedTab,
handleCloseAgentTab,
handleClosePassiveTab,
handleCloseTerminalTab,
],
);
const handleCopyAgentId = useCallback(
async (agentId: string) => {
if (!agentId) return;
try {
await Clipboard.setStringAsync(agentId);
toast.copied(t("workspace.tabs.toasts.agentIdCopiedLabel"));
} catch {
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[toast, t],
);
const handleCopyTerminalId = useCallback(
async (terminalId: string) => {
if (!terminalId) return;
try {
await Clipboard.setStringAsync(terminalId);
toast.copied(t("workspace.tabs.toasts.terminalIdCopiedLabel"));
} catch {
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[toast, t],
);
const handleCopyFilePath = useCallback(
async (path: string) => {
if (!path) return;
try {
await Clipboard.setStringAsync(path);
toast.copied(t("workspace.tabs.toasts.filePathCopiedLabel"));
} catch {
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[toast, t],
);
const handleCopyResumeCommand = useCallback(
async (agentId: string) => {
if (!agentId) return;
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const providerSessionId =
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
if (!agent || !providerSessionId) {
toast.error(t("workspace.tabs.toasts.resumeIdUnavailable"));
return;
}
const command =
buildProviderCommand({
provider: agent.provider,
id: "resume",
sessionId: providerSessionId,
}) ?? null;
if (!command) {
toast.error(t("workspace.tabs.toasts.resumeCommandUnavailable"));
return;
}
try {
await Clipboard.setStringAsync(command);
toast.copied(t("workspace.tabs.toasts.resumeCommandCopiedLabel"));
} catch {
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[normalizedServerId, toast, t],
);
const handleReloadAgent = useCallback(
async (agentId: string) => {
if (!client || !isConnected) {
toast.error(t("workspace.terminal.hostDisconnected"));
return;
}
toast.show(t("workspace.tabs.toasts.reloadingAgent"), { durationMs: null });
try {
await client.refreshAgent(agentId);
// Send the existing cursor so the server detects the new epoch and
// returns reset:true. Without a cursor, the server returns reset:false
// and the client takes the incremental path, where new-epoch rows are
// dropped against the stale cursor.
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
direction: "tail",
projection: "projected",
...(currentCursor
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
});
toast.show(t("workspace.tabs.toasts.reloadedAgent"), { variant: "success" });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("workspace.tabs.toasts.failedToReloadAgent"),
);
}
},
[client, isConnected, normalizedServerId, toast, t],
);
const handleCopyWorkspacePath = useCallback(async () => {
if (!workspaceDirectory) {
toast.error(t("workspace.header.toasts.workspacePathUnavailable"));
return;
}
try {
await Clipboard.setStringAsync(workspaceDirectory);
toast.copied(t("workspace.header.toasts.workspacePathCopiedLabel"));
} catch {
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
}, [toast, workspaceDirectory, t]);
const handleCopyBranchName = useCallback(async () => {
if (!currentBranchName) {
toast.error(t("workspace.header.toasts.branchNameUnavailable"));
return;
}
try {
await Clipboard.setStringAsync(currentBranchName);
toast.copied(t("workspace.header.toasts.branchNameCopiedLabel"));
} catch {
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
}, [currentBranchName, toast, t]);
const handleOpenSetupTab = useCallback(() => {
if (!persistenceKey) {
return;
}
const target = normalizeWorkspaceTabTarget({
kind: "setup",
workspaceId: normalizedWorkspaceId,
});
if (!target) {
return;
}
openWorkspaceTabFocused(persistenceKey, target);
}, [normalizedWorkspaceId, openWorkspaceTabFocused, persistenceKey]);
const handleBulkCloseTabs = useCallback(
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
const { tabsToClose, title, logLabel } = input;
if (tabsToClose.length === 0) {
return;
}
const groups = classifyBulkClosableTabs(tabsToClose);
const modifiedCount = tabsToClose.filter(
(tab) =>
getPanelInstanceAttributes({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
}).modified,
).length;
const bulkMessage = buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels);
const confirmed = await confirmDialog({
title,
message:
modifiedCount > 0
? `${bulkMessage}\n\n${t("workspace.tabs.confirmations.bulkUnsaved", { count: modifiedCount })}`
: bulkMessage,
confirmLabel: t("workspace.tabs.confirmations.close"),
cancelLabel: t("workspace.tabs.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await closeBulkWorkspaceTabs({
client,
groups,
closeTab,
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
return;
}
closeWorkspaceTabWithCleanup(cleanupInput);
},
logLabel,
warn: (message, payload) => {
console.warn(message, payload);
},
});
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
bulkCloseConfirmationLabels,
client,
closeTab,
closeWorkspaceTabWithCleanup,
normalizedServerId,
normalizedWorkspaceId,
persistenceKey,
t,
],
);
const handleCloseTabsToLeftInPane = useCallback(
async (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => {
const index = paneTabs.findIndex((tab) => tab.tabId === tabId);
if (index < 0) {
return;
}
await handleBulkCloseTabs({
tabsToClose: paneTabs.slice(0, index),
title: t("workspace.tabs.confirmations.closeTabsLeftTitle"),
logLabel: "to the left",
});
},
[handleBulkCloseTabs, t],
);
const handleCloseTabsToLeft = useCallback(
async (tabId: string) => {
await handleCloseTabsToLeftInPane(tabId, tabs);
},
[handleCloseTabsToLeftInPane, tabs],
);
const handleCloseTabsToRightInPane = useCallback(
async (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => {
const index = paneTabs.findIndex((tab) => tab.tabId === tabId);
if (index < 0) {
return;
}
await handleBulkCloseTabs({
tabsToClose: paneTabs.slice(index + 1),
title: t("workspace.tabs.confirmations.closeTabsRightTitle"),
logLabel: "to the right",
});
},
[handleBulkCloseTabs, t],
);
const handleCloseTabsToRight = useCallback(
async (tabId: string) => {
await handleCloseTabsToRightInPane(tabId, tabs);
},
[handleCloseTabsToRightInPane, tabs],
);
const handleCloseOtherTabsInPane = useCallback(
async (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => {
const tabsToClose = paneTabs.filter((tab) => tab.tabId !== tabId);
await handleBulkCloseTabs({
tabsToClose,
title: t("workspace.tabs.confirmations.closeOtherTabsTitle"),
logLabel: "from close other tabs",
});
},
[handleBulkCloseTabs, t],
);
const handleCloseOtherTabs = useCallback(
async (tabId: string) => {
await handleCloseOtherTabsInPane(tabId, tabs);
},
[handleCloseOtherTabsInPane, tabs],
);
const handleWorkspaceTabAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
switch (action.id) {
case "workspace.tab.new":
handleCreateDraftTab();
return true;
case "workspace.terminal.new":
handleCreateTerminal();
return true;
case "workspace.browser.new":
handleCreateBrowserTab();
return true;
case "workspace.tab.close-current":
if (activeTabId) {
void handleCloseTabById(activeTabId);
}
return true;
case "workspace.tab.navigate-index": {
const next = tabs[action.index - 1] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
return true;
}
case "workspace.tab.navigate-relative": {
if (tabs.length > 0) {
const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId);
const fromIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex = (fromIndex + action.delta + tabs.length) % tabs.length;
const next = tabs[nextIndex] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
}
return true;
}
default:
return false;
}
},
[
activeTabId,
handleCloseTabById,
handleCreateDraftTab,
handleCreateBrowserTab,
handleCreateTerminal,
navigateToTabId,
tabs,
],
);
const handleWorkspaceSidebarAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "sidebar.toggle.right") {
return false;
}
handleToggleExplorer();
return true;
},
[handleToggleExplorer],
);
const handleWorkspacePaneAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id === "workspace.focus.toggle") {
toggleFocusMode();
return true;
}
if (!persistenceKey || !workspaceLayout) {
return true;
}
const focusedPane = focusedPaneTabState.pane;
if (!focusedPane) {
return true;
}
if (action.id === "workspace.pane.split.right") {
handleCreateDraftSplit({
targetPaneId: focusedPane.id,
position: "right",
});
return true;
}
if (action.id === "workspace.pane.split.down") {
handleCreateDraftSplit({
targetPaneId: focusedPane.id,
position: "bottom",
});
return true;
}
if (action.id.startsWith("workspace.pane.focus.")) {
const direction = parsePaneDirection(action.id);
if (direction) {
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
if (adjacentPaneId) {
focusWorkspacePane(persistenceKey, adjacentPaneId);
}
}
return true;
}
if (action.id.startsWith("workspace.pane.move-tab.")) {
const direction = parsePaneDirection(action.id);
if (direction) {
const activePaneTabId = focusedPaneTabState.activeTabId;
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
if (activePaneTabId && adjacentPaneId) {
paneFocusSuppressedRef.current = true;
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
requestAnimationFrame(() => {
paneFocusSuppressedRef.current = false;
});
}
}
return true;
}
if (action.id === "workspace.pane.close") {
const tabsToClose = focusedPane.tabIds.flatMap((tabId) => {
const tab = allTabDescriptorsById.get(tabId);
return tab ? [tab] : [];
});
void handleBulkCloseTabs({
tabsToClose,
title: t("workspace.tabs.confirmations.closePaneTitle"),
logLabel: "from pane close",
});
return true;
}
return false;
},
[
allTabDescriptorsById,
focusWorkspacePane,
handleBulkCloseTabs,
handleCreateDraftSplit,
moveWorkspaceTabToPane,
persistenceKey,
focusedPaneTabState.activeTabId,
focusedPaneTabState.pane,
toggleFocusMode,
t,
workspaceLayout,
],
);
useKeyboardActionHandler({
handlerId: `workspace-tab-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: [
"workspace.tab.new",
"workspace.tab.close-current",
"workspace.tab.navigate-index",
"workspace.tab.navigate-relative",
"workspace.terminal.new",
"workspace.browser.new",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100,
isActive: () => true,
handle: handleWorkspaceTabAction,
});
useKeyboardActionHandler({
handlerId: `workspace-pane-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: [
"workspace.pane.split.right",
"workspace.pane.split.down",
"workspace.pane.focus.left",
"workspace.pane.focus.right",
"workspace.pane.focus.up",
"workspace.pane.focus.down",
"workspace.pane.move-tab.left",
"workspace.pane.move-tab.right",
"workspace.pane.move-tab.up",
"workspace.pane.move-tab.down",
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100,
isActive: () => true,
handle: handleWorkspacePaneAction,
});
useKeyboardActionHandler({
handlerId: `workspace-sidebar-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: ["sidebar.toggle.right"] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
});
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
const activeFilePath = activeFileFields.path;
const activeFileLineStart = activeFileFields.lineStart;
const activeFileLineEnd = activeFileFields.lineEnd;
const activeFileLocation = useMemo<WorkspaceFileLocation | null>(
() =>
buildWorkspaceFileLocation({
path: activeFilePath,
lineStart: activeFileLineStart,
lineEnd: activeFileLineEnd,
}),
[activeFileLineEnd, activeFileLineStart, activeFilePath],
);
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = useMemo(
() => !isMobile && !canRenderDesktopPaneSplits,
[isMobile, canRenderDesktopPaneSplits],
);
useEffect(() => {
if (!isRouteFocused || isNative || typeof document === "undefined" || activeTabDescriptor) {
return;
}
document.title = "Workspace";
}, [activeTabDescriptor, isRouteFocused]);
const buildPaneContentModel = useCallback(
(input: {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
focusPaneBeforeOpen?: boolean;
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
normalizedServerId,
normalizedWorkspaceId,
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
onOpenTab: (target) => {
if (!persistenceKey) {
return;
}
if (input.focusPaneBeforeOpen && input.paneId) {
focusWorkspacePane(persistenceKey, input.paneId);
}
const tabId = openWorkspaceChildTabFocused(persistenceKey, target, input.tab.tabId);
if (tabId) {
navigateToTabId(tabId);
}
},
onCloseCurrentTab: () => {
void handleCloseTabById(input.tab.tabId);
},
onRetargetCurrentTab: (target) => {
if (!persistenceKey) {
return;
}
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
},
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
handleOpenWorkspaceFileFromPane({
request,
paneId: input.paneId,
parentTabId: input.tab.tabId,
focusPaneBeforeOpen: input.focusPaneBeforeOpen,
});
},
onOpenImportSheet: openImportSheet,
}),
[
handleCloseTabById,
focusWorkspacePane,
fileNavigationRevisionByTabId,
handleOpenWorkspaceFileFromPane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
openImportSheet,
openWorkspaceChildTabFocused,
persistenceKey,
retargetWorkspaceTab,
],
);
const focusedPaneId = useMemo(
() => focusedPaneTabState.pane?.id ?? null,
[focusedPaneTabState.pane],
);
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
const modifiedFocusedPaneTabIds = useModifiedPanelTabIds({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabIds: focusedPaneTabIds,
});
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
activeTabId,
allTabIds: focusedPaneTabIds,
retainedTabIds: modifiedFocusedPaneTabIds,
cap: 3,
});
const mountedFocusedPaneTabIds = useMemo(
() => focusedPaneTabIds.filter((tabId) => mountedFocusedPaneTabIdsSet.has(tabId)),
[focusedPaneTabIds, mountedFocusedPaneTabIdsSet],
);
const buildMobilePaneContentModel = useCallback(
function buildMobilePaneContentModel(input: {
paneId: string | null;
tab: WorkspaceTabDescriptor;
}) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
focusPaneBeforeOpen: false,
});
},
[buildPaneContentModel],
);
const content = renderWorkspaceContent({
isMissingWorkspaceDirectory,
activeTabDescriptor,
hasHydratedAgents,
mountedFocusedPaneTabIds,
focusedPaneTabDescriptorMap,
isRouteFocused,
focusedPaneId,
buildMobilePaneContentModel,
});
const buildDesktopPaneContentModel = useCallback(
function buildDesktopPaneContentModel(input: { paneId: string; tab: WorkspaceTabDescriptor }) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
focusPaneBeforeOpen: true,
});
},
[buildPaneContentModel],
);
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
tabs.map((tab) => ({
tab,
isActive: tab.tabId === activeTabDescriptor?.tabId,
isCloseHovered: hoveredCloseTabKey === tab.key,
isClosingTab: closingTabIds.has(tab.tabId),
})),
[activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs],
);
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
if (!persistenceKey || paneFocusSuppressedRef.current) {
return;
}
focusWorkspacePane(persistenceKey, paneId);
});
const handleSplitPane = useCallback(
function handleSplitPane(input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) {
if (!persistenceKey) {
return;
}
splitWorkspacePane(persistenceKey, input);
},
[persistenceKey, splitWorkspacePane],
);
const handleMoveTabToPane = useCallback(
function handleMoveTabToPane(tabId: string, toPaneId: string) {
if (!persistenceKey) {
return;
}
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
},
[moveWorkspaceTabToPane, persistenceKey],
);
const handleResizePaneSplit = useCallback(
function handleResizePaneSplit(groupId: string, sizes: number[]) {
if (!persistenceKey) {
return;
}
resizeWorkspaceSplit(persistenceKey, groupId, sizes);
},
[persistenceKey, resizeWorkspaceSplit],
);
const handleReorderTabsInPane = useCallback(
function handleReorderTabsInPane(paneId: string, tabIds: string[]) {
if (!persistenceKey) {
return;
}
reorderWorkspaceTabsInPane(persistenceKey, paneId, tabIds);
},
[persistenceKey, reorderWorkspaceTabsInPane],
);
const handleReorderTabsInFocusedPane = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
if (!focusedPaneId) {
return;
}
handleReorderTabsInPane(
focusedPaneId,
nextTabs.map((tab) => tab.tabId),
);
},
[focusedPaneId, handleReorderTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(
function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>{t("workspace.tabs.emptyPane")}</Text>
</View>
);
},
[t],
);
const containerStyle = [styles.container, styles.containerWorkspaceBackground];
const menuNewAgentIcon = MENU_NEW_AGENT_ICON;
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,
onRecoverWorkspace: workspaceRecovery.restore,
onRetryRecoveryInspection: workspaceRecovery.retryInspection,
},
});
const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({
gate: workspaceScreenGate,
workspaceKey: persistenceKey,
});
const headerRight = useMemo(
() => (
<View style={styles.headerRight}>
{!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
hideLabels
/>
) : null}
{!isMobile && workspaceDirectory ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={workspaceDirectory}
activeFile={activeFileLocation}
hideLabels
/>
) : null}
{!isMobile && workspaceDirectory ? (
<>
<WorkspaceActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
{isGitCheckout ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = hovered || pressed;
const colorMapping = active ? foregroundColorMapping : extraMutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
) : null}
</>
) : null}
{!isMobile && !isGitCheckout ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel={t("workspace.tabs.explorer.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={[styles.compactHeaderActionButton, styles.explorerPanelButton]}
accessible
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered, pressed }) => {
const colorMapping =
hovered || pressed ? foregroundColorMapping : extraMutedColorMapping;
return <ThemedPanelRight size={16} uniProps={colorMapping} />;
}}
</HeaderToggleButton>
) : null}
{isMobile ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel={t("workspace.tabs.explorer.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
>
{({ hovered }) => {
const colorMapping =
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
return isGitCheckout ? (
<ThemedSourceControlPanelIcon
size={20}
uniProps={colorMapping}
{...sourceControlPanelStrokeWidth15}
/>
) : (
<ThemedPanelRight size={20} uniProps={colorMapping} />
);
}}
</HeaderToggleButton>
) : null}
</View>
),
[
isMobile,
workspaceDescriptor,
normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
showCompactButtonLabels,
isGitCheckout,
handleToggleExplorer,
isExplorerOpen,
explorerToggleLabel,
explorerToggleAccessibilityState,
explorerToggleStyle,
t,
],
);
const showScreenHeader = useMemo(
() => shouldShowWorkspaceScreenHeader({ isFocusModeEnabled, isMobile }),
[isFocusModeEnabled, isMobile],
);
const showExplorerSidebar = useMemo(
() => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }),
[isRouteFocused, isFocusModeEnabled, isMobile],
);
const createTerminalDisabled = useMemo(
() => createTerminalMutation.isPending || pendingTerminalCreateInput !== null,
[createTerminalMutation.isPending, pendingTerminalCreateInput],
);
const showCreateBrowserTab = getIsElectron();
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
const desktopFocusModeEnabled = useMemo(
() => isFocusModeEnabled && !isMobile,
[isFocusModeEnabled, isMobile],
);
const workspaceFloatingPanelPortalHostName = useMemo(
() =>
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
[normalizedServerId, normalizedWorkspaceId],
);
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return null;
}
return (
<SplitContainer
layout={workspaceLayout}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
workspaceKey={persistenceKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
uiTabs={uiTabs}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onCreateBrowserTab={handleCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane}
onSplitPane={handleSplitPane}
onSplitPaneEmpty={handleCreateDraftSplit}
onMoveTabToPane={handleMoveTabToPane}
onResizeSplit={handleResizePaneSplit}
onReorderTabsInPane={handleReorderTabsInPane}
renderPaneEmptyState={renderSplitPaneEmptyState}
/>
);
}, [
canRenderDesktopPaneSplits,
workspaceLayout,
persistenceKey,
desktopFocusModeEnabled,
toggleFocusMode,
normalizedServerId,
normalizedWorkspaceId,
isRouteFocused,
uiTabs,
hoveredCloseTabKey,
closingTabIds,
navigateToTabId,
handleCloseTabById,
handleCopyResumeCommand,
handleCopyAgentId,
handleCopyTerminalId,
handleCopyFilePath,
handleReloadAgent,
handleRenameTab,
handleCloseTabsToLeftInPane,
handleCloseTabsToRightInPane,
handleCloseOtherTabsInPane,
handleCreateDraftTab,
handleCreateTerminal,
handleCreateBrowserTab,
showCreateBrowserTab,
buildDesktopPaneContentModel,
handleFocusPane,
handleSplitPane,
handleCreateDraftSplit,
handleMoveTabToPane,
handleResizePaneSplit,
handleReorderTabsInPane,
renderSplitPaneEmptyState,
]);
const desktopContent = desktopSplitContent ?? content;
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
{showScreenHeader && (
<ScreenHeader
onRowLayout={onHeaderLayout}
left={
<>
<SidebarMenuToggle />
<WorkspaceHeaderTitleBar
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
currentBranchName={currentBranchName}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
workspaceScripts={workspaceScripts}
liveTerminalIds={liveTerminalIds}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuImportIcon={MENU_IMPORT_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewScriptTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
/>
</>
}
right={headerRight}
/>
)}
{isMobile ? (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
activeTab={activeTabDescriptor}
tabSwitcherOptions={tabSwitcherOptions}
tabByKey={tabByKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
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}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
) : null}
<View style={styles.centerContent}>
{isMobile ? (
<View style={styles.content}>{content}</View>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
</View>
</View>
);
return (
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<WorkspaceChromeRow
portalHostName={workspaceFloatingPanelPortalHostName}
showExplorerSidebar={showExplorerSidebar}
explorerOpen={isExplorerOpen}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
>
{workspaceCenterColumn}
</WorkspaceChromeRow>
<ImportSessionSheet
visible={isRouteFocused && isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
workspaceId={normalizedWorkspaceId}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={isRouteFocused ? renamingTab : null}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
</WorkspaceFocusProvider>
)
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
containerWorkspaceBackground: {
backgroundColor: theme.colors.surfaceWorkspace,
},
threePaneRow: {
flex: 1,
minHeight: 0,
flexDirection: "row",
alignItems: "stretch",
},
centerColumn: {
flex: 1,
minHeight: 0,
},
// Compact steps the whole header down one rung of the scale. The title and the project line
// stack there, so at the shared size they read as two headings rather than a subject and its
// caption, and they cost two full lines of a header that has none to spare.
headerTitle: {
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
},
headerTitleContainer: {
flex: 1,
flexShrink: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: {
xs: theme.spacing[1],
md: theme.spacing[2],
},
overflow: "hidden",
},
headerTitleTextGroup: {
minWidth: 0,
overflow: "hidden",
flexShrink: 1,
flexGrow: {
xs: 1,
md: 0,
},
flexDirection: {
xs: "column",
md: "row",
},
alignItems: {
xs: "flex-start",
md: "center",
},
justifyContent: "flex-start",
gap: {
xs: 0,
md: theme.spacing[2],
},
},
// No width cap. A percentage cap resolves against the title group, whose own width comes from
// this row's content, so it clips the project name while there is still room beside it.
// `flexShrink` on both this row and the title already gives up space only when there is none.
headerProjectRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
flexShrink: 1,
},
headerProjectTitle: {
color: theme.colors.foregroundMuted,
fontSize: {
xs: theme.fontSize.xs,
md: theme.fontSize.base,
},
flexShrink: 1,
minWidth: 0,
},
headerProjectSeparator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
headerTitleSkeleton: {
width: 220,
maxWidth: "100%",
height: 22,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
opacity: 0.25,
},
headerRight: {
flexDirection: "row",
alignItems: "center",
gap: {
xs: theme.spacing[1],
md: theme.spacing[2],
},
},
headerActionButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
compactHeaderActionButton: {
width: theme.spacing[8],
height: theme.spacing[8],
padding: 0,
borderRadius: theme.borderRadius.lg,
alignItems: "center",
justifyContent: "center",
},
explorerPanelButton: {
// The 32px trigger and 16px panel glyph leave 8px more trailing space than
// the 22px split-pane trigger and its 14px glyph in the row below.
marginRight: -theme.spacing[2],
},
compactHeaderMenuCluster: {
flexDirection: "row",
alignItems: "center",
gap: {
xs: 0,
md: theme.spacing[2],
},
},
sourceControlButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
borderRadius: theme.borderRadius.md,
// Match the painted right edge of the trailing split-pane glyph below. The
// two header rows intentionally use different control sizes and padding.
marginRight: -7,
},
sourceControlButtonWithStats: {
paddingHorizontal: theme.spacing[3],
},
sourceControlButtonHovered: {
backgroundColor: theme.colors.surface2,
},
newTabActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
newTabActionButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
newTabTooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
newTabTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
newTabTooltipShortcut: {},
explorerTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
explorerTooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
explorerTooltipShortcut: {},
mobileTabsRow: {
backgroundColor: theme.colors.surface0,
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
switcherTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2] + theme.spacing[3],
paddingVertical: theme.spacing[2],
},
switcherTriggerPressed: {
backgroundColor: theme.colors.surface1,
},
switcherTriggerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
switcherTriggerIcon: {
flexShrink: 0,
},
switcherTriggerText: {
minWidth: 0,
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
headerMenuProfileIconWrapper: {
width: 16,
height: 16,
},
tabsContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
flexDirection: "row",
alignItems: "center",
},
tabsScroll: {
flex: 1,
minWidth: 0,
},
tabsContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
tabsActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
centerContent: {
flex: 1,
minHeight: 0,
},
tab: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
maxWidth: 260,
},
tabHandle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
tabIcon: {
flexShrink: 0,
},
tabActive: {
backgroundColor: theme.colors.surface2,
},
tabHovered: {
backgroundColor: theme.colors.surface2,
},
tabLabel: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tabLabelWithCloseButton: {
paddingRight: 0,
},
tabLabelActive: {
color: theme.colors.foreground,
},
tabCloseButton: {
width: 18,
height: 18,
marginLeft: 0,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
tabCloseButtonShown: {
opacity: 1,
},
tabCloseButtonHidden: {
opacity: 0,
},
tabCloseButtonActive: {
backgroundColor: theme.colors.surface3,
},
content: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
position: "relative",
},
mobileMountedTabSlot: {
...StyleSheet.absoluteFillObject,
},
contentPlaceholder: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
emptyStateText: {
color: theme.colors.foregroundMuted,
textAlign: "center",
},
}));
const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];