Step the title and project name down one rung of the scale on compact, and put the host on the project line. On a phone this header is the only thing on screen that names the machine, because the sidebar carrying that badge is closed. It follows each host's own badge setting, so a local-only setup stays quiet, and it yields space before the project name does. A project name that only repeats the workspace name is still dropped where the two sit side by side, and kept on compact, where the line exists for the host anyway. That made shouldShowSubtitle a verdict the header source was not in a position to reach, so it now reports the fact: isSubtitleDistinct. Drop the 60% cap on the project name. It resolved against a box whose own width came from that same text, so it clipped while there was still room beside it; flexShrink already gives up space only when there is none.
4297 lines
133 KiB
TypeScript
4297 lines
133 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 {
|
|
CopyX,
|
|
ArrowLeftToLine,
|
|
ArrowRightToLine,
|
|
ChevronDown,
|
|
Copy,
|
|
Ellipsis,
|
|
EllipsisVertical,
|
|
Globe,
|
|
Import as ImportIcon,
|
|
PanelRight,
|
|
Pencil,
|
|
RotateCw,
|
|
Settings,
|
|
SquarePen,
|
|
SquareTerminal,
|
|
X,
|
|
} 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 { 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 {
|
|
WorkspaceDesktopTabsRow,
|
|
type WorkspaceDesktopTabRowItem,
|
|
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
|
import {
|
|
buildWorkspaceTabMenuEntries,
|
|
type WorkspaceTabMenuEntry,
|
|
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 { 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 {
|
|
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 ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
|
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
const ThemedCopy = withUnistyles(Copy);
|
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
|
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
|
const ThemedCopyX = withUnistyles(CopyX);
|
|
const ThemedPencil = withUnistyles(Pencil);
|
|
const ThemedX = withUnistyles(X);
|
|
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,
|
|
}: {
|
|
activeTab: WorkspaceTabDescriptor | null;
|
|
normalizedServerId: string;
|
|
normalizedWorkspaceId: string;
|
|
}) {
|
|
if (!activeTab) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<ResolvedMobileActiveTabTrigger
|
|
activeTab={activeTab}
|
|
normalizedServerId={normalizedServerId}
|
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ResolvedMobileActiveTabTrigger({
|
|
activeTab,
|
|
normalizedServerId,
|
|
normalizedWorkspaceId,
|
|
}: {
|
|
activeTab: WorkspaceTabDescriptor;
|
|
normalizedServerId: string;
|
|
normalizedWorkspaceId: string;
|
|
}) {
|
|
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 />
|
|
</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 mobileTabMenuTriggerStyle({ open, pressed }: { open?: boolean; pressed?: boolean }) {
|
|
return [
|
|
styles.mobileTabMenuTrigger,
|
|
(Boolean(open) || Boolean(pressed)) && styles.mobileTabMenuTriggerActive,
|
|
];
|
|
}
|
|
|
|
function switcherTriggerStyle({ pressed }: { pressed?: boolean }) {
|
|
return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed];
|
|
}
|
|
|
|
function MobileTabTrailingAccessory({
|
|
menuTestIDBase,
|
|
presentationLabel,
|
|
menuEntries,
|
|
}: {
|
|
menuTestIDBase: string;
|
|
presentationLabel: string;
|
|
menuEntries: WorkspaceTabMenuEntry[];
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID={`${menuTestIDBase}-trigger`}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("workspace.tabs.menu.openFor", { label: presentationLabel })}
|
|
hitSlop={8}
|
|
style={mobileTabMenuTriggerStyle}
|
|
>
|
|
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
|
|
{menuEntries.map((entry) =>
|
|
entry.kind === "separator" ? (
|
|
<DropdownMenuSeparator key={entry.key} />
|
|
) : (
|
|
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
|
),
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function MobileTabDropdownMenuItem({
|
|
entry,
|
|
}: {
|
|
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
|
}) {
|
|
const leading = useMemo(() => {
|
|
switch (entry.icon) {
|
|
case "copy":
|
|
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
|
case "rotate-cw":
|
|
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
|
case "arrow-left-to-line":
|
|
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
|
case "arrow-right-to-line":
|
|
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
|
case "copy-x":
|
|
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
|
case "pencil":
|
|
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
|
case "x":
|
|
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}, [entry.icon]);
|
|
const trailing = useMemo(
|
|
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
|
[entry.hint],
|
|
);
|
|
return (
|
|
<DropdownMenuItem
|
|
testID={entry.testID}
|
|
disabled={entry.disabled}
|
|
destructive={entry.destructive}
|
|
onSelect={entry.onSelect}
|
|
tooltip={entry.tooltip}
|
|
leading={leading}
|
|
trailing={trailing}
|
|
>
|
|
{entry.label}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
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}
|
|
>
|
|
<View style={styles.switcherTriggerLeft}>
|
|
<MobileActiveTabTrigger
|
|
activeTab={activeTab}
|
|
normalizedServerId={normalizedServerId}
|
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
|
/>
|
|
</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();
|
|
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({
|
|
name: profile.name,
|
|
command: profile.command,
|
|
args: profile.args,
|
|
});
|
|
}, [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>
|
|
);
|
|
}
|
|
|
|
function WorkspaceHeaderMenuTriggerIcon({
|
|
hovered,
|
|
open,
|
|
isMobile,
|
|
}: {
|
|
hovered: boolean;
|
|
open: boolean;
|
|
isMobile: boolean;
|
|
}) {
|
|
const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
|
|
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
|
|
return <Icon 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} isMobile={isMobile} />
|
|
),
|
|
[isMobile],
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID="workspace-header-menu-trigger"
|
|
style={isMobile ? styles.compactHeaderActionButton : styles.headerActionButton}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
|
|
>
|
|
{renderTriggerIcon}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" width={220} testID="workspace-header-menu">
|
|
<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,
|
|
},
|
|
mobileTabMenuTrigger: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: theme.borderRadius.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
mobileTabMenuTriggerActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
menuItemHint: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
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"];
|