feat: projects → workspaces → tabs

This commit is contained in:
Mohamed Boudra
2026-03-01 18:22:11 +07:00
parent 2e201cab09
commit 080fb74642
43 changed files with 4054 additions and 2654 deletions
+7 -6
View File
@@ -34,7 +34,6 @@ import {
} from "@/utils/navigation-timing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
interface AgentListProps {
@@ -85,7 +84,7 @@ interface AgentListRowProps {
agent: AggregatedAgent;
selectedAgentId?: string;
showCheckoutInfo: boolean;
onPress: (serverId: string, agentId: string) => void;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}
@@ -117,7 +116,7 @@ function AgentListRow({
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => onPress(agent.serverId, agent.id)}
onPress={() => onPress(agent)}
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
@@ -169,11 +168,13 @@ export function AgentList({
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(serverId: string, agentId: string) => {
(agent: AggregatedAgent) => {
if (isActionSheetVisible) {
return;
}
const serverId = agent.serverId;
const agentId = agent.id;
const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, {
from: "home",
@@ -181,12 +182,12 @@ export function AgentList({
params: { serverId, agentId },
});
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const shouldReplace = pathname.startsWith("/h/");
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.();
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
navigate(buildHostAgentDetailRoute(serverId, agentId, agent.cwd) as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);
@@ -173,8 +173,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd ?? workspaceRoot;
const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } =
useFileExplorerActions(resolvedServerId);
useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceRoot,
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -204,13 +210,13 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
requestDirectoryListing(agentId, normalized.directory, {
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
if (normalized.file) {
selectExplorerEntry(agentId, normalized.file);
requestFilePreview(agentId, normalized.file);
selectExplorerEntry(normalized.file);
void requestFilePreview(normalized.file);
}
setExplorerTabForCheckout({
@@ -223,7 +229,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
},
[
agent.cwd,
agentId,
requestDirectoryListing,
requestFilePreview,
selectExplorerEntry,
@@ -21,7 +21,6 @@ import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animati
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { TerminalPane } from "./terminal-pane";
const MIN_CHAT_WIDTH = 400;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
@@ -46,12 +45,17 @@ function resolveKeyboardShift(rawHeight: number, inset: number): number {
interface ExplorerSidebarProps {
serverId: string;
agentId: string;
cwd: string;
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
}
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
isGit,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
@@ -121,9 +125,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[cwd, isGit, serverId, setExplorerTabForCheckout]
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
);
// Swipe gesture to close (swipe right on mobile)
@@ -132,7 +136,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(enableSidebarCloseGesture)
// Use manual activation so child views (e.g. WebView terminals) keep touch streams
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
.onTouchesDown((event) => {
@@ -301,8 +305,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
onTabPress={handleTabPress}
onClose={() => handleClose("header-close-button")}
serverId={serverId}
agentId={agentId}
cwd={cwd}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={isMobile}
/>
@@ -334,8 +338,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
agentId={agentId}
cwd={cwd}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
/>
@@ -348,8 +352,8 @@ interface SidebarContentProps {
onTabPress: (tab: ExplorerTab) => void;
onClose: () => void;
serverId: string;
agentId: string;
cwd: string;
workspaceId?: string | null;
workspaceRoot: string;
isGit: boolean;
isMobile: boolean;
}
@@ -359,8 +363,8 @@ function SidebarContent({
onTabPress,
onClose,
serverId,
agentId,
cwd,
workspaceId,
workspaceRoot,
isGit,
isMobile,
}: SidebarContentProps) {
@@ -403,20 +407,6 @@ function SidebarContent({
Files
</Text>
</Pressable>
<Pressable
testID="explorer-tab-terminals"
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
onPress={() => onTabPress("terminals")}
>
<Text
style={[
styles.tabText,
resolvedTab === "terminals" && styles.tabTextActive,
]}
>
Terminals
</Text>
</Pressable>
</View>
<View style={styles.headerRightSection}>
{isMobile && (
@@ -430,13 +420,18 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
/>
)}
{resolvedTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
{resolvedTab === "terminals" && (
<TerminalPane serverId={serverId} cwd={cwd} />
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
/>
)}
</View>
</View>
@@ -60,6 +60,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
@@ -81,7 +82,8 @@ const INDENT_PER_LEVEL = 12;
interface FileExplorerPaneProps {
serverId: string;
agentId: string;
workspaceId?: string | null;
workspaceRoot: string;
}
interface TreeRow {
@@ -89,7 +91,11 @@ interface TreeRow {
depth: number;
}
export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
export function FileExplorerPane({
serverId,
workspaceId,
workspaceRoot,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -100,23 +106,40 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
);
const agentExists = useSessionStore((state) =>
agentId && state.sessions[serverId]
? state.sessions[serverId]?.agents.has(agentId)
: false
const normalizedWorkspaceRoot = useMemo(
() => workspaceRoot.trim(),
[workspaceRoot]
);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId]
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId]
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
agentId && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(agentId)
workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined
);
const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
selectExplorerEntry,
} = useFileExplorerActions(serverId);
} = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
@@ -155,20 +178,29 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
const hasInitializedRef = useRef(false);
useEffect(() => {
if (!agentId || !requestDirectoryListing) {
hasInitializedRef.current = false;
setExpandedPaths(new Set(["."]));
}, [actionsWorkspaceStateKey]);
useEffect(() => {
if (!hasWorkspaceScope) {
return;
}
if (hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false });
}, [agentId, requestDirectoryListing]);
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
}, [hasWorkspaceScope, requestDirectoryListing]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => {
if (!agentId || !selectedEntryPath || !requestDirectoryListing) {
if (!selectedEntryPath || !hasWorkspaceScope) {
return;
}
const parentDir = getParentDirectory(selectedEntryPath);
@@ -182,10 +214,13 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
ancestors.forEach((path) => {
if (!directories.has(path)) {
requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false });
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
});
}, [agentId, directories, requestDirectoryListing, selectedEntryPath]);
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
// Open/close preview sheet based on selection
useEffect(() => {
@@ -200,15 +235,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}, [isMobile, selectedEntryPath]);
const handleClosePreview = useCallback(() => {
if (!agentId) {
return;
}
selectExplorerEntry(agentId, null);
}, [agentId, selectExplorerEntry]);
selectExplorerEntry(null);
}, [selectExplorerEntry]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => {
if (!agentId || !requestDirectoryListing) {
if (!hasWorkspaceScope) {
return;
}
@@ -225,21 +257,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
});
if (nextExpanded && !directories.has(entry.path)) {
requestDirectoryListing(agentId, entry.path, { recordHistory: false, setCurrentPath: false });
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
});
}
},
[agentId, directories, expandedPaths, requestDirectoryListing]
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
);
const handleOpenFile = useCallback(
(entry: ExplorerEntry) => {
if (!agentId || !requestFilePreview) {
if (!hasWorkspaceScope) {
return;
}
selectExplorerEntry(agentId, entry.path);
requestFilePreview(agentId, entry.path);
selectExplorerEntry(entry.path);
void requestFilePreview(entry.path);
},
[agentId, requestFilePreview, selectExplorerEntry]
[hasWorkspaceScope, requestFilePreview, selectExplorerEntry]
);
const handleEntryPress = useCallback(
@@ -260,20 +295,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => {
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
agentId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload]
[
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
);
const handleSortCycle = useCallback(() => {
@@ -283,9 +324,9 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}, [sortOption, setSortOption]);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, agentId],
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
queryFn: async () => {
if (!agentId) {
if (!hasWorkspaceScope) {
return null;
}
@@ -296,12 +337,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
await Promise.all([
...directoryPaths.map((path) =>
requestDirectoryListing(agentId, path, {
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
})
),
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []),
...(selectedEntryPath ? [requestFilePreview(selectedEntryPath)] : []),
]);
return null;
},
@@ -587,15 +628,15 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
);
const handleBackFromError = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
if (!hasWorkspaceScope) {
return;
}
selectExplorerEntry(agentId, null);
requestDirectoryListing(agentId, errorRecoveryPath, {
selectExplorerEntry(null);
void requestDirectoryListing(errorRecoveryPath, {
recordHistory: false,
setCurrentPath: true,
});
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -615,10 +656,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
[showDesktopWebScrollbar, treeScrollbarMetrics]
);
if (!agentExists) {
if (!hasWorkspaceScope) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
<Text style={styles.errorText}>Workspace is unavailable</Text>
</View>
);
}
@@ -640,12 +681,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<Pressable
style={styles.retryButton}
onPress={() => {
if (agentId) {
requestDirectoryListing(agentId, ".", {
recordHistory: false,
setCurrentPath: false,
});
}
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
}}
>
<Text style={styles.retryButtonText}>Retry</Text>
@@ -464,7 +464,7 @@ function DiffFileBody({
interface GitDiffPaneProps {
serverId: string;
agentId: string;
workspaceId?: string | null;
cwd: string;
}
@@ -472,7 +472,7 @@ type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -750,7 +750,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
perfLog(DIFF_PANE_LOG_TAG, {
event: "files_snapshot",
serverId,
agentId,
workspaceId: workspaceId ?? cwd,
fileCount: diffMetrics.fileCount,
hunkCount: diffMetrics.hunkCount,
lineCount: diffMetrics.lineCount,
@@ -758,7 +758,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
isLoading: isDiffLoading,
isFetching: isDiffFetching,
});
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
}, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
+174 -336
View File
@@ -1,70 +1,62 @@
import {
useCallback,
useMemo,
useState,
useEffect,
useRef,
useSyncExternalStore,
} from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Plus, ListFilter, Settings, Users } from "lucide-react-native";
import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { Plus, Settings, Users } from 'lucide-react-native'
import { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store'
import { SidebarAgentList } from './sidebar-agent-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { Combobox } from '@/components/ui/combobox'
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { useSessionStore } from '@/stores/session-store'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
} from '@/hooks/use-checkout-status-query'
import { queryClient } from '@/query/query-client'
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
resolveSelectedAgentForNewAgent,
} from "@/utils/new-agent-routing";
} from '@/utils/new-agent-routing'
import {
buildHostAgentsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes";
} from '@/utils/host-routes'
const DESKTOP_SIDEBAR_WIDTH = 320;
const DESKTOP_SIDEBAR_WIDTH = 320
interface LeftSidebarProps {
selectedAgentId?: string;
selectedAgentId?: string
}
export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const runtime = getHostRuntimeStore();
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const mobileView = usePanelStore((state) => state.mobileView)
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const { daemons } = useDaemonRegistry()
const runtime = getHostRuntimeStore()
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
@@ -72,76 +64,58 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
)
.join("|"),
.join('|'),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
)
.join("|")
);
const activeServerIdFromPath = useMemo(
() => parseServerIdFromPathname(pathname),
[pathname]
);
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null;
.join('|')
)
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null
const activeHostLabel = useMemo(() => {
if (!activeServerId) return "No host";
const daemon = daemons.find((entry) => entry.serverId === activeServerId);
const trimmed = daemon?.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeServerId;
}, [activeServerId, daemons]);
if (!activeServerId) return 'No host'
const daemon = daemons.find((entry) => entry.serverId === activeServerId)
const trimmed = daemon?.label?.trim()
return trimmed && trimmed.length > 0 ? trimmed : activeServerId
}, [activeServerId, daemons])
const activeHostStatus = activeServerId
? runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting"
: "idle";
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: 'idle'
const activeHostStatusColor =
activeHostStatus === "online"
activeHostStatus === 'online'
? theme.colors.palette.green[400]
: activeHostStatus === "connecting"
: activeHostStatus === 'connecting'
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500];
: theme.colors.palette.red[500]
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
),
})),
[daemons, runtime, runtimeConnectionStatusSignature]
);
const hostTriggerRef = useRef<View>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const projectFilterAnchorRef = useRef<View>(null);
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
)
const hostTriggerRef = useRef<View>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectFilterKey, setSelectedProjectFilterKey] = useState<string | null>(
null
);
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const {
entries,
projectFilterOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsList({
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarAgentsList({
serverId: activeServerId,
selectedProjectFilterKey,
});
useEffect(() => {
setSelectedProjectFilterKey(null);
}, [activeServerId]);
enabled: isOpen,
})
const {
translateX,
backdropOpacity,
@@ -150,157 +124,116 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation();
const dragHandlers = useTauriDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
} = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [isManualRefresh, setIsManualRefresh] = useState(false)
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
setIsManualRefresh(true)
refreshAll()
}, [refreshAll])
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
setIsManualRefresh(false)
}
}, [isRevalidating, isManualRefresh]);
}, [isRevalidating, isManualRefresh])
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.setSidebarShortcutAgentKeys
);
const sidebarShortcutAgentKeys = useMemo(() => {
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
}, [entries]);
)
useEffect(() => {
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
}, [setSidebarShortcutAgentKeys, sidebarShortcutAgentKeys]);
setSidebarShortcutAgentKeys([])
}, [setSidebarShortcutAgentKeys])
const handleClose = useCallback(() => {
closeToAgent();
}, [closeToAgent]);
closeToAgent()
}, [closeToAgent])
const handleCreateAgentClean = useCallback(() => {
let targetServerId = activeServerId;
let targetWorkingDir: string | null = null;
let targetServerId = activeServerId
let targetWorkingDir: string | null = null
const selectedAgent = resolveSelectedAgentForNewAgent({
pathname,
selectedAgentId,
});
})
if (selectedAgent) {
targetServerId = selectedAgent.serverId;
targetServerId = selectedAgent.serverId
const agent = useSessionStore
.getState()
.sessions[selectedAgent.serverId]
?.agents?.get(selectedAgent.agentId);
const cwd = agent?.cwd?.trim();
.sessions[selectedAgent.serverId]?.agents?.get(selectedAgent.agentId)
const cwd = agent?.cwd?.trim()
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
) ?? null
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout)
}
}
if (!targetServerId) {
return;
return
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
}, [activeServerId, pathname, selectedAgentId]);
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any)
}, [activeServerId, pathname, selectedAgentId])
// Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => {
closeToAgent();
handleCreateAgentClean();
}, [closeToAgent, handleCreateAgentClean]);
closeToAgent()
handleCreateAgentClean()
}, [closeToAgent, handleCreateAgentClean])
// Desktop: just navigate, don't close
const handleCreateAgentCleanDesktop = useCallback(() => {
handleCreateAgentClean();
}, [handleCreateAgentClean]);
handleCreateAgentClean()
}, [handleCreateAgentClean])
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return;
return
}
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
closeToAgent()
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
// Desktop: just navigate, don't close
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return;
return
}
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
// Mobile: close sidebar when agent is selected
// Snap immediately since navigation interrupts animations
const handleAgentSelectMobile = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
}, [closeToAgent, translateX, backdropOpacity, windowWidth]);
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return;
return
}
if (isMobile) {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
}
router.push(buildHostAgentsRoute(activeServerId) as any);
}, [
activeServerId,
backdropOpacity,
closeToAgent,
isMobile,
translateX,
windowWidth,
]);
const listFooterComponent = useMemo(() => {
if (!hasMoreEntries) {
return null;
}
return (
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
{({ hovered }) => (
<Text
style={[
styles.listViewMoreButtonText,
hovered && styles.listViewMoreButtonTextHovered,
]}
>
View more
</Text>
)}
</Pressable>
);
}, [handleViewMore, hasMoreEntries]);
router.push(buildHostAgentsRoute(activeServerId) as any)
}, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return;
return
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
const nextPath = mapPathnameToServer(pathname, nextServerId)
setIsHostPickerOpen(false)
router.push(nextPath as any)
},
[pathname]
);
)
// Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement
@@ -315,50 +248,48 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
// Fail if vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onStart(() => {
isGesturing.value = true;
isGesturing.value = true
})
.onUpdate((event) => {
if (!isMobile) return;
if (!isMobile) return
// Only allow swiping left (closing)
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
)
})
.onEnd((event) => {
isGesturing.value = false;
if (!isMobile) return;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
isGesturing.value = false
if (!isMobile) return
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
animateToClose()
runOnJS(handleClose)()
} else {
animateToOpen();
animateToOpen()
}
})
.onFinalize(() => {
isGesturing.value = false;
});
isGesturing.value = false
})
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
}))
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
}))
// Render mobile sidebar
// On web, keep the overlay interactive only while the sidebar is open.
// This preserves swipe/scroll behavior without blocking taps when closed.
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -391,55 +322,30 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
<Text
style={[
styles.newAgentButtonText,
hovered && styles.newAgentButtonTextHovered,
]}
>
New agent
</Text>
</>
)}
</Pressable>
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<ListFilter
size={theme.iconSize.md}
color={
selectedProjectFilterKey
? theme.colors.accentForeground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : null}
</View>
</View>
{/* Middle: scrollable agent list */}
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
isOpen={isOpen}
serverId={activeServerId}
entries={entries}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
@@ -457,10 +363,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
disabled={hostOptions.length === 0}
>
<View
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
@@ -505,7 +408,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -519,12 +422,12 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</Animated.View>
</GestureDetector>
</View>
);
)
}
// Desktop: no edge swipe, just show/hide based on isOpen
if (!isOpen) {
return null;
return null
}
return (
@@ -532,10 +435,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View
style={styles.sidebarHeader}
{...dragHandlers}
>
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
@@ -548,54 +448,27 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
New agent
</Text>
</>
)}
</Pressable>
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<ListFilter
size={theme.iconSize.md}
color={
selectedProjectFilterKey
? theme.colors.accentForeground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : null}
</View>
</View>
{/* Middle: scrollable agent list */}
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
isOpen={isOpen}
serverId={activeServerId}
entries={entries}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
/>
)}
@@ -611,12 +484,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/>
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
@@ -660,7 +528,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -671,29 +539,29 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
/>
</View>
</View>
);
)
}
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
overflow: 'hidden',
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
overflow: 'hidden',
},
desktopSidebar: {
borderRightWidth: 1,
@@ -706,36 +574,25 @@ const styles = StyleSheet.create((theme) => ({
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
justifyContent: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: "none",
userSelect: 'none',
},
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: theme.spacing[2],
},
newAgentButton: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
flexShrink: 0,
},
filterButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
filterButtonActive: {
backgroundColor: theme.colors.accent,
},
newAgentButtonHovered: {},
newAgentButtonText: {
fontSize: theme.fontSize.base,
@@ -746,9 +603,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
@@ -773,9 +630,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
@@ -788,38 +645,19 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
alignItems: 'center',
justifyContent: 'center',
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
listViewMoreButton: {
marginTop: theme.spacing[2],
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[2],
},
listViewMoreButtonText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
listViewMoreButtonTextHovered: {
color: theme.colors.foreground,
},
hostPickerList: {
gap: theme.spacing[2],
},
@@ -842,10 +680,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
alignItems: 'center',
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
}))
File diff suppressed because it is too large Load Diff
+162 -110
View File
@@ -44,6 +44,10 @@ import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
selectedTerminalId?: string | null;
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
hideHeader?: boolean;
manageTerminalDirectorySubscription?: boolean;
}
const MAX_OUTPUT_CHARS = 200_000;
@@ -130,7 +134,14 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
);
}
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
export function TerminalPane({
serverId,
cwd,
selectedTerminalId: selectedTerminalIdProp,
onSelectedTerminalIdChange,
hideHeader = false,
manageTerminalDirectorySubscription = true,
}: TerminalPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -145,8 +156,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
const isSelectedTerminalControlled =
typeof selectedTerminalIdProp !== "undefined";
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] =
useState<string | null>(null);
const selectedTerminalId = isSelectedTerminalControlled
? selectedTerminalIdProp ?? null
: uncontrolledSelectedTerminalId;
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
sequence: 0,
text: "",
@@ -182,9 +199,13 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
? (next as (value: string | null) => string | null)(current)
: next;
selectedTerminalIdRef.current = resolved;
setSelectedTerminalId(resolved);
if (isSelectedTerminalControlled) {
onSelectedTerminalIdChange?.(resolved);
return;
}
setUncontrolledSelectedTerminalId(resolved);
},
[]
[isSelectedTerminalControlled, onSelectedTerminalIdChange]
);
useEffect(() => {
@@ -344,7 +365,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [client, isConnected, queryClient, terminalsQueryKey]);
useEffect(() => {
if (!client || !isConnected || !cwd.startsWith("/")) {
if (
!manageTerminalDirectorySubscription ||
!client ||
!isConnected ||
!cwd.startsWith("/")
) {
return;
}
@@ -370,7 +396,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
unsubscribe();
client.unsubscribeTerminals({ cwd });
};
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
}, [
client,
cwd,
isConnected,
manageTerminalDirectorySubscription,
queryClient,
terminalsQueryKey,
]);
const createTerminalMutation = useMutation({
mutationFn: async () => {
@@ -394,7 +427,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
if (!isSelectedTerminalControlled) {
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
}
updateSelectedTerminalId(createdTerminal.id);
requestTerminalFocus();
}
@@ -435,15 +470,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
});
useEffect(() => {
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
if (!isSelectedTerminalControlled) {
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
}
lastReportedSizeRef.current = null;
}, [scopeKey, updateSelectedTerminalId]);
}, [isSelectedTerminalControlled, scopeKey, updateSelectedTerminalId]);
useEffect(() => {
if (isSelectedTerminalControlled) {
return;
}
if (selectedTerminalId) {
selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId);
}
}, [scopeKey, selectedTerminalId]);
}, [isSelectedTerminalControlled, scopeKey, selectedTerminalId]);
useEffect(() => {
if (terminals.length === 0) {
@@ -458,7 +498,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return;
}
const stored = selectedTerminalByScopeRef.current.get(scopeKey);
const stored = isSelectedTerminalControlled
? null
: selectedTerminalByScopeRef.current.get(scopeKey);
if (has(stored)) {
updateSelectedTerminalId(stored!);
return;
@@ -466,10 +508,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const fallback = terminals[0]?.id ?? null;
if (fallback) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
if (!isSelectedTerminalControlled) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
}
updateSelectedTerminalId(fallback);
}
}, [scopeKey, terminals, selectedTerminalId, updateSelectedTerminalId]);
}, [
isSelectedTerminalControlled,
scopeKey,
terminals,
selectedTerminalId,
updateSelectedTerminalId,
]);
useEffect(() => {
const terminalIds = terminals.map((terminal) => terminal.id);
@@ -911,108 +961,110 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return (
<View style={styles.container}>
<View style={styles.header} testID="terminals-header">
<ScrollView
horizontal
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
{terminals.map((terminal) => {
const isActive = terminal.id === selectedTerminalId;
const isTabHovered = hoveredTerminalId === terminal.id;
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
const isClosingTerminal =
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton =
isTabHovered || isCloseHovered || isClosingTerminal;
const gradientId = `terminal-close-gradient-${terminal.id.replace(
/[^a-zA-Z0-9_-]/g,
"-"
)}`;
return (
<Pressable
key={terminal.id}
testID={`terminal-tab-${terminal.id}`}
onPress={() => updateSelectedTerminalId(terminal.id)}
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
style={({ pressed, hovered }) => [
styles.terminalTab,
isActive && styles.terminalTabActive,
shouldShowCloseButton && styles.terminalTabHovered,
(pressed || hovered) && styles.terminalTabHovered,
]}
>
<Text
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
numberOfLines={1}
ellipsizeMode="tail"
>
{terminal.name}
</Text>
{!hideHeader ? (
<View style={styles.header} testID="terminals-header">
<ScrollView
horizontal
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
{terminals.map((terminal) => {
const isActive = terminal.id === selectedTerminalId;
const isTabHovered = hoveredTerminalId === terminal.id;
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
const isClosingTerminal =
killTerminalMutation.isPending &&
killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton =
isTabHovered || isCloseHovered || isClosingTerminal;
const gradientId = `terminal-close-gradient-${terminal.id.replace(
/[^a-zA-Z0-9_-]/g,
"-"
)}`;
return (
<Pressable
testID={`terminal-close-${terminal.id}`}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTerminal}
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
onPress={(event) => {
event.stopPropagation();
void handleCloseTerminal(terminal.id);
}}
style={({ hovered, pressed }) => [
styles.terminalTabCloseButton,
shouldShowCloseButton
? styles.terminalTabCloseButtonShown
: styles.terminalTabCloseButtonHidden,
key={terminal.id}
testID={`terminal-tab-${terminal.id}`}
onPress={() => updateSelectedTerminalId(terminal.id)}
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
style={({ pressed, hovered }) => [
styles.terminalTab,
isActive && styles.terminalTabActive,
shouldShowCloseButton && styles.terminalTabHovered,
(pressed || hovered) && styles.terminalTabHovered,
]}
>
{({ hovered = false, pressed = false }) => {
const iconColor =
hovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<TerminalCloseGradient
color={theme.colors.surface2}
gradientId={gradientId}
/>
<View style={styles.terminalTabCloseIcon}>
{isClosingTerminal ? (
<ActivityIndicator size={12} color={iconColor} />
) : (
<X size={12} color={iconColor} />
)}
</View>
</>
);
}}
<Text
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
numberOfLines={1}
ellipsizeMode="tail"
>
{terminal.name}
</Text>
<Pressable
testID={`terminal-close-${terminal.id}`}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTerminal}
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
onPress={(event) => {
event.stopPropagation();
void handleCloseTerminal(terminal.id);
}}
style={({ hovered, pressed }) => [
styles.terminalTabCloseButton,
shouldShowCloseButton
? styles.terminalTabCloseButtonShown
: styles.terminalTabCloseButtonHidden,
]}
>
{({ hovered = false, pressed = false }) => {
const iconColor =
hovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<TerminalCloseGradient
color={theme.colors.surface2}
gradientId={gradientId}
/>
<View style={styles.terminalTabCloseIcon}>
{isClosingTerminal ? (
<ActivityIndicator size={12} color={iconColor} />
) : (
<X size={12} color={iconColor} />
)}
</View>
</>
);
}}
</Pressable>
</Pressable>
</Pressable>
);
})}
</ScrollView>
<View style={styles.headerActions}>
<Pressable
testID="terminals-create-button"
onPress={handleCreateTerminal}
disabled={isCreating}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isCreating ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Plus size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
);
})}
</ScrollView>
<View style={styles.headerActions}>
<Pressable
testID="terminals-create-button"
onPress={handleCreateTerminal}
disabled={isCreating}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isCreating ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Plus size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
</View>
</View>
) : null}
<View style={styles.outputContainer}>
{selectedTerminal ? (