feat: projects → workspaces → tabs
This commit is contained in:
@@ -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" })
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user