+
{renderItem(info)}
);
@@ -102,6 +118,7 @@ export function DraggableList
({
renderItem,
onDragEnd,
style,
+ containerStyle,
contentContainerStyle,
testID,
ListFooterComponent,
@@ -109,6 +126,8 @@ export function DraggableList({
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
+ scrollEnabled = true,
+ useDragHandle = false,
// simultaneousGestureRef is native-only, ignored on web
onDragBegin,
}: DraggableListProps) {
@@ -161,52 +180,90 @@ export function DraggableList({
);
const ids = items.map((item, index) => keyExtractor(item, index));
- const showCustomScrollbar = enableDesktopWebScrollbar;
+ const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
+ const wrapperStyle = [
+ { position: "relative" as const },
+ scrollEnabled ? { flex: 1, minHeight: 0 } : null,
+ containerStyle,
+ ];
return (
-
-
- {ListHeaderComponent}
- {items.length === 0 && ListEmptyComponent}
-
+ {scrollEnabled ? (
+
-
- {items.map((item, index) => {
- const id = keyExtractor(item, index);
- return (
-
- );
- })}
-
-
- {ListFooterComponent}
-
+ {ListHeaderComponent}
+ {items.length === 0 && ListEmptyComponent}
+
+
+ {items.map((item, index) => {
+ const id = keyExtractor(item, index);
+ return (
+
+ );
+ })}
+
+
+ {ListFooterComponent}
+
+ ) : (
+ <>
+ {ListHeaderComponent}
+ {items.length === 0 && ListEmptyComponent}
+
+
+ {items.map((item, index) => {
+ const id = keyExtractor(item, index);
+ return (
+
+ );
+ })}
+
+
+ {ListFooterComponent}
+ >
+ )}
): void {
@@ -33,16 +32,6 @@ function logExplorerSidebar(event: string, details: Record): vo
console.log(`[ExplorerSidebar] ${event}`, details);
}
-function resolveKeyboardShift(rawHeight: number, inset: number): number {
- "worklet";
- // iOS can report a small accessory/prediction bar height during touch focus.
- // Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
- if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
- return 0;
- }
- return Math.max(0, rawHeight - inset);
-}
-
interface ExplorerSidebarProps {
serverId: string;
workspaceId?: string | null;
@@ -69,14 +58,13 @@ export function ExplorerSidebar({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
- const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
- const bottomInset = useSharedValue(insets.bottom);
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
- useEffect(() => {
- bottomInset.value = insets.bottom;
- }, [bottomInset, insets.bottom]);
+ const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
+ mode: "padding",
+ enabled: isMobile,
+ });
useEffect(() => {
if (isMobile) {
@@ -257,14 +245,6 @@ export function ExplorerSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
- const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
- const absoluteHeight = Math.abs(keyboardHeight.value);
- const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
- return {
- paddingBottom: bottomInset.value + shift,
- };
- });
-
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
@@ -322,7 +302,9 @@ export function ExplorerSidebar({
}
return (
-
+
{/* Resize handle - absolutely positioned over left border */}
{
+ const touch = event.changedTouches[0]
+ if (!touch) {
+ return
+ }
+ closeTouchStartX.value = touch.absoluteX
+ closeTouchStartY.value = touch.absoluteY
+ })
+ .onTouchesMove((event, stateManager) => {
+ const touch = event.changedTouches[0]
+ if (!touch || event.numberOfTouches !== 1) {
+ stateManager.fail()
+ return
+ }
+
+ const deltaX = touch.absoluteX - closeTouchStartX.value
+ const deltaY = touch.absoluteY - closeTouchStartY.value
+ const absDeltaX = Math.abs(deltaX)
+ const absDeltaY = Math.abs(deltaY)
+
+ // Fail quickly on clear rightward or vertical intent so child views keep control.
+ if (deltaX >= 10) {
+ stateManager.fail()
+ return
+ }
+ if (absDeltaY > 10 && absDeltaY > absDeltaX) {
+ stateManager.fail()
+ return
+ }
+
+ // Activate only on intentional leftward movement.
+ if (deltaX <= -15 && absDeltaX > absDeltaY) {
+ stateManager.activate()
+ }
+ })
.onStart(() => {
isGesturing.value = true
})
diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx
index 780c8e6af..71baed489 100644
--- a/packages/app/src/components/sidebar-agent-list.tsx
+++ b/packages/app/src/components/sidebar-agent-list.tsx
@@ -9,20 +9,21 @@ import {
type ReactElement,
type MutableRefObject,
} from 'react'
-import { router, usePathname } from 'expo-router'
+import { router, useSegments } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
-import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes'
+import { buildHostWorkspaceRoute } from '@/utils/host-routes'
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-agents-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { formatTimeAgo } from '@/utils/time'
+import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
type SidebarTreeRow =
| {
@@ -68,7 +69,6 @@ interface ProjectRowProps {
interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry
- compact?: boolean
onPress: () => void
onLongPress: () => void
}
@@ -116,21 +116,23 @@ function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): strin
return formatTimeAgo(workspace.createdAt)
}
-function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucket'] }) {
+function resolveStatusDotColor(input: { theme: ReturnType['theme']; bucket: SidebarStateBucket }) {
+ const { theme, bucket } = input
+ return bucket === 'needs_input'
+ ? theme.colors.palette.amber[500]
+ : bucket === 'failed'
+ ? theme.colors.palette.red[500]
+ : bucket === 'running'
+ ? theme.colors.palette.blue[500]
+ : bucket === 'attention'
+ ? theme.colors.palette.green[500]
+ : theme.colors.border
+}
+
+function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
const { theme } = useUnistyles()
-
- const color =
- bucket === 'needs_input'
- ? theme.colors.palette.amber[500]
- : bucket === 'failed'
- ? theme.colors.palette.red[500]
- : bucket === 'running'
- ? theme.colors.palette.blue[500]
- : bucket === 'attention'
- ? theme.colors.palette.green[500]
- : theme.colors.border
-
- return
+ const color = resolveStatusDotColor({ theme, bucket })
+ return
}
function ProjectRow({
@@ -185,19 +187,15 @@ function ProjectRow({
)}
-
-
{displayName}
-
- {project.workspaces.length}
)
}
-function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: WorkspaceRowProps) {
+function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
const didLongPressRef = useRef(false)
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
@@ -218,7 +216,6 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
[
styles.workspaceRow,
- compact && styles.workspaceRowCompact,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
@@ -227,9 +224,12 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
-
- {resolveWorkspaceBranchLabel(workspace)}
-
+
+
+
+ {resolveWorkspaceBranchLabel(workspace)}
+
+
{createdAtLabel ? (
{createdAtLabel}
@@ -273,7 +273,8 @@ export function SidebarAgentList({
}: SidebarAgentListProps) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
- const pathname = usePathname()
+ const segments = useSegments()
+ const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set())
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
@@ -419,13 +420,11 @@ export function SidebarAgentList({
}
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
- const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname))
- const navigate = shouldReplace ? router.replace : router.push
+ const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
return (
{
if (!serverId) {
return
@@ -441,9 +440,9 @@ export function SidebarAgentList({
collapsedProjectKeys,
isMobile,
onWorkspacePress,
- pathname,
projectIconByProjectKey,
serverId,
+ shouldReplaceWorkspaceNavigation,
toggleProjectCollapsed,
]
)
@@ -603,27 +602,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: 9,
},
- projectStatusDot: {
- width: 8,
- height: 8,
- borderRadius: theme.borderRadius.full,
- },
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
minWidth: 0,
},
- projectCountText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- flexShrink: 0,
- },
workspaceRow: {
- minHeight: 34,
+ minHeight: 36,
marginBottom: theme.spacing[1],
- marginLeft: theme.spacing[4],
- paddingVertical: theme.spacing[1],
+ paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
@@ -631,9 +619,12 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: 'space-between',
gap: theme.spacing[2],
},
- workspaceRowCompact: {
- marginLeft: theme.spacing[3],
- paddingHorizontal: theme.spacing[1],
+ workspaceRowLeft: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: theme.spacing[2],
+ flex: 1,
+ minWidth: 0,
},
workspaceRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -641,9 +632,15 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
+ workspaceStatusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: theme.borderRadius.full,
+ flexShrink: 0,
+ },
workspaceBranchText: {
color: theme.colors.foreground,
- fontSize: theme.fontSize.xs,
+ fontSize: theme.fontSize.sm,
flex: 1,
minWidth: 0,
},
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index c59b1eb42..85442390a 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -9,6 +9,7 @@ import {
View,
} from "react-native";
import { Plus, X } from "lucide-react-native";
+import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
@@ -19,6 +20,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
+import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
@@ -145,6 +147,10 @@ export function TerminalPane({
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+ const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
+ mode: "padding",
+ enabled: isMobile,
+ });
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(serverId);
@@ -185,6 +191,7 @@ export function TerminalPane({
const hoverOutTimeoutRef = useRef | null>(null);
const selectedTerminalIdRef = useRef(selectedTerminalId);
const pendingTerminalInputRef = useRef([]);
+ const keyboardRefitTimeoutsRef = useRef>>([]);
const updateSelectedTerminalId = useCallback(
(
@@ -298,6 +305,41 @@ export function TerminalPane({
setResizeRequestToken((current) => current + 1);
}, []);
+ const clearKeyboardRefitTimeouts = useCallback(() => {
+ if (keyboardRefitTimeoutsRef.current.length === 0) {
+ return;
+ }
+ for (const handle of keyboardRefitTimeoutsRef.current) {
+ clearTimeout(handle);
+ }
+ keyboardRefitTimeoutsRef.current = [];
+ }, []);
+
+ const pulseKeyboardRefits = useCallback(() => {
+ clearKeyboardRefitTimeouts();
+ requestTerminalReflow();
+ keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
+ setTimeout(() => {
+ requestTerminalReflow();
+ }, delayMs)
+ );
+ }, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
+
+ useEffect(() => {
+ return () => clearKeyboardRefitTimeouts();
+ }, [clearKeyboardRefitTimeouts]);
+
+ useAnimatedReaction(
+ () => keyboardShift.value > 0,
+ (next, prev) => {
+ if (next === prev) {
+ return;
+ }
+ runOnJS(pulseKeyboardRefits)();
+ },
+ [pulseKeyboardRefits]
+ );
+
useFocusEffect(
useCallback(() => {
if (!selectedTerminalId) {
@@ -353,16 +395,8 @@ export function TerminalPane({
streamId: message.payload.streamId,
});
setModifiers({ ...EMPTY_MODIFIERS });
-
- void queryClient.invalidateQueries({
- queryKey: terminalsQueryKey,
- });
- void queryClient.refetchQueries({
- queryKey: terminalsQueryKey,
- type: "active",
- });
});
- }, [client, isConnected, queryClient, terminalsQueryKey]);
+ }, [client, isConnected]);
useEffect(() => {
if (
@@ -381,13 +415,12 @@ export function TerminalPane({
if (message.payload.cwd !== cwd) {
return;
}
- void queryClient.invalidateQueries({
- queryKey: terminalsQueryKey,
- });
- void queryClient.refetchQueries({
- queryKey: terminalsQueryKey,
- type: "active",
- });
+
+ queryClient.setQueryData(terminalsQueryKey, (current) => ({
+ cwd: message.payload.cwd,
+ terminals: message.payload.terminals,
+ requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
+ }));
});
client.subscribeTerminals({ cwd });
@@ -960,7 +993,7 @@ export function TerminalPane({
const combinedError = streamError ?? closeError ?? createError ?? queryError;
return (
-
+
{!hideHeader ? (
) : null}
-
+
);
}
diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts
new file mode 100644
index 000000000..bff828d37
--- /dev/null
+++ b/packages/app/src/hooks/use-keyboard-shift-style.ts
@@ -0,0 +1,84 @@
+import { useEffect } from "react";
+import { Platform } from "react-native";
+import type { ViewStyle } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
+import {
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ type SharedValue,
+} from "react-native-reanimated";
+
+const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
+
+function resolveKeyboardShift(input: {
+ rawKeyboardHeight: number;
+ bottomInset: number;
+ isIos: boolean;
+ iosMinHeight: number;
+ enabled: boolean;
+}): number {
+ "worklet";
+
+ if (!input.enabled) {
+ return 0;
+ }
+
+ // iOS can report a small accessory/prediction bar height during touch focus.
+ // Treat that as non-keyboard so layouts don't "bounce" while interacting.
+ if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
+ return 0;
+ }
+
+ return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
+}
+
+type KeyboardShiftMode = "translate" | "padding";
+
+export function useKeyboardShiftStyle(input: {
+ mode: KeyboardShiftMode;
+ enabled?: boolean;
+ iosMinHeight?: number;
+}): {
+ shift: SharedValue;
+ style: ReturnType>;
+} {
+ const insets = useSafeAreaInsets();
+ const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
+ const bottomInset = useSharedValue(insets.bottom);
+ const enabled = input.enabled ?? true;
+ const isIos = Platform.OS === "ios";
+ const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT;
+
+ useEffect(() => {
+ bottomInset.value = insets.bottom;
+ }, [bottomInset, insets.bottom]);
+
+ const shift = useDerivedValue(() => {
+ "worklet";
+ const rawKeyboardHeight = Math.abs(keyboardHeight.value);
+ return resolveKeyboardShift({
+ rawKeyboardHeight,
+ bottomInset: bottomInset.value,
+ isIos,
+ iosMinHeight,
+ enabled,
+ });
+ });
+
+ const style = useAnimatedStyle(() => {
+ "worklet";
+ if (input.mode === "padding") {
+ if (!enabled) {
+ return { paddingBottom: 0 };
+ }
+ // Include safe-area bottom inset so content clears the home indicator even without a keyboard.
+ return { paddingBottom: bottomInset.value + shift.value };
+ }
+
+ return { transform: [{ translateY: -shift.value }] };
+ }, [input.mode]);
+
+ return { shift, style };
+}
diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts
index 75e65120c..d9fa55fdd 100644
--- a/packages/app/src/hooks/use-sidebar-agents-list.ts
+++ b/packages/app/src/hooks/use-sidebar-agents-list.ts
@@ -25,6 +25,7 @@ export interface SidebarWorkspaceEntry {
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
+ statusBucket: SidebarStateBucket
}
export interface SidebarProjectEntry {
@@ -54,6 +55,7 @@ interface MutableWorkspaceEntry {
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
+ statusBucket: SidebarStateBucket
}
interface MutableProjectEntry {
@@ -256,6 +258,7 @@ function ensureWorkspace(
createdAt: input.createdAt,
isMainCheckout: input.isMainCheckout,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree,
+ statusBucket: 'done',
}
project.workspacesByKey.set(workspaceKey, workspace)
return workspace
@@ -394,6 +397,7 @@ export function useSidebarAgentsList(options?: {
isMainCheckout,
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
})
+ workspace.statusBucket = aggregateBucket(workspace.statusBucket, bucket)
const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot)
if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) {
@@ -573,6 +577,7 @@ export function useSidebarAgentsList(options?: {
createdAt: workspace.createdAt,
isMainCheckout: workspace.isMainCheckout,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
+ statusBucket: workspace.statusBucket,
})
)
diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx
index f1a22430e..f88656b61 100644
--- a/packages/app/src/screens/agent/agent-ready-screen.tsx
+++ b/packages/app/src/screens/agent/agent-ready-screen.tsx
@@ -12,12 +12,8 @@ import { useRouter } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQueryClient } from "@tanstack/react-query";
-import ReanimatedAnimated, {
- useAnimatedStyle,
- useSharedValue,
-} from "react-native-reanimated";
+import ReanimatedAnimated from "react-native-reanimated";
import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
@@ -78,6 +74,7 @@ import {
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
+import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -508,20 +505,8 @@ function AgentScreenContent({
[serverId]
);
- const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
- const bottomInset = useSharedValue(insets.bottom);
-
- useEffect(() => {
- bottomInset.value = insets.bottom;
- }, [insets.bottom, bottomInset]);
-
- const animatedKeyboardStyle = useAnimatedStyle(() => {
- "worklet";
- const absoluteHeight = Math.abs(keyboardHeight.value);
- const shift = Math.max(0, absoluteHeight - bottomInset.value);
- return {
- transform: [{ translateY: -shift }],
- };
+ const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
+ mode: "translate",
});
const handleHistorySyncFailure = useCallback(
diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx
index 04651c3da..61132a9db 100644
--- a/packages/app/src/screens/agent/draft-agent-screen.tsx
+++ b/packages/app/src/screens/agent/draft-agent-screen.tsx
@@ -6,9 +6,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { useIsFocused } from '@react-navigation/native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import { GestureDetector } from 'react-native-gesture-handler'
-import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
+import Animated from 'react-native-reanimated'
import { Folder, GitBranch, PanelRight } from 'lucide-react-native'
import { SidebarMenuToggle } from '@/components/headers/menu-header'
import { HeaderToggleButton } from '@/components/headers/header-toggle-button'
@@ -51,6 +50,7 @@ import type {
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostAgentDetailRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window'
+import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
const DRAFT_AGENT_ID = '__new_agent__'
const EMPTY_PENDING_PERMISSIONS = new Map()
@@ -148,20 +148,8 @@ function DraftAgentScreenContent({
)
const params = useLocalSearchParams()
- const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
- const bottomInset = useSharedValue(insets.bottom)
-
- useEffect(() => {
- bottomInset.value = insets.bottom
- }, [insets.bottom, bottomInset])
-
- const animatedKeyboardStyle = useAnimatedStyle(() => {
- 'worklet'
- const absoluteHeight = Math.abs(keyboardHeight.value)
- const shift = Math.max(0, absoluteHeight - bottomInset.value)
- return {
- transform: [{ translateY: -shift }],
- }
+ const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
+ mode: 'translate',
})
const forcedServerIdParam = forcedServerId?.trim()
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 4ad4c22f2..e3988f665 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -35,6 +35,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { TerminalPane } from "@/components/terminal-pane";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -62,6 +63,9 @@ import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
+import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
+import { getStatusDotColor } from "@/utils/status-dot-color";
+import { useArchiveAgent } from "@/hooks/use-archive-agent";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const DROPDOWN_WIDTH = 220;
@@ -358,6 +362,7 @@ function WorkspaceScreenContent({
return payload;
},
});
+ const { archiveAgent, isArchivingAgent } = useArchiveAgent();
useEffect(() => {
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
@@ -371,16 +376,18 @@ function WorkspaceScreenContent({
if (message.payload.cwd !== normalizedWorkspaceId) {
return;
}
- void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
- void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" });
+
+ queryClient.setQueryData(terminalsQueryKey, (current) => ({
+ cwd: message.payload.cwd,
+ terminals: message.payload.terminals,
+ requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
+ }));
});
const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit") {
return;
}
- void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
- void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" });
});
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
@@ -819,14 +826,10 @@ function WorkspaceScreenContent({
[handleCreateAgent, handleCreateTerminal]
);
- const getTabAfterClosingTerminal = useCallback(
- (terminalId: string): WorkspaceTabTarget | null => {
- const currentIndex = tabs.findIndex(
- (tab) => tab.kind === "terminal" && tab.terminalId === terminalId
- );
- const nextTabs = tabs.filter(
- (tab) => !(tab.kind === "terminal" && tab.terminalId === terminalId)
- );
+ const getTabAfterClosing = useCallback(
+ (tabKey: string): WorkspaceTabTarget | null => {
+ const currentIndex = tabs.findIndex((tab) => tab.key === tabKey);
+ const nextTabs = tabs.filter((tab) => tab.key !== tabKey);
if (nextTabs.length === 0) {
return null;
}
@@ -870,11 +873,21 @@ function WorkspaceScreenContent({
current === tabKey ? null : current
);
- if (
- resolvedTab?.kind === "terminal" &&
- resolvedTab.terminalId === terminalId
- ) {
- const nextTab = getTabAfterClosingTerminal(terminalId);
+ queryClient.setQueryData(
+ terminalsQueryKey,
+ (current) => {
+ if (!current) {
+ return current;
+ }
+ return {
+ ...current,
+ terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
+ };
+ }
+ );
+
+ if (resolvedTab?.kind === "terminal" && resolvedTab.terminalId === terminalId) {
+ const nextTab = getTabAfterClosing(`terminal:${terminalId}`);
if (nextTab) {
navigateToTab(nextTab);
} else {
@@ -886,17 +899,11 @@ function WorkspaceScreenContent({
);
}
}
-
- void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
- void queryClient.refetchQueries({
- queryKey: terminalsQueryKey,
- type: "active",
- });
},
});
},
[
- getTabAfterClosingTerminal,
+ getTabAfterClosing,
killTerminalMutation,
navigateToTab,
normalizedServerId,
@@ -908,6 +915,55 @@ function WorkspaceScreenContent({
]
);
+ const handleCloseAgentTab = useCallback(
+ async (agentId: string) => {
+ if (!normalizedServerId || isArchivingAgent({ serverId: normalizedServerId, agentId })) {
+ return;
+ }
+
+ const confirmed = await confirmDialog({
+ title: "Archive agent?",
+ message: "This closes the tab and archives the agent.",
+ confirmLabel: "Archive",
+ cancelLabel: "Cancel",
+ destructive: true,
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ await archiveAgent({ serverId: normalizedServerId, agentId });
+
+ const tabKey = `agent:${agentId}`;
+ setHoveredTabKey((current) => (current === tabKey ? null : current));
+ setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
+
+ if (resolvedTab?.kind === "agent" && resolvedTab.agentId === agentId) {
+ const nextTab = getTabAfterClosing(tabKey);
+ if (nextTab) {
+ navigateToTab(nextTab);
+ } else {
+ router.replace(
+ buildHostWorkspaceRoute(
+ normalizedServerId,
+ normalizedWorkspaceId
+ ) as any
+ );
+ }
+ }
+ },
+ [
+ archiveAgent,
+ getTabAfterClosing,
+ isArchivingAgent,
+ navigateToTab,
+ normalizedServerId,
+ normalizedWorkspaceId,
+ resolvedTab,
+ router,
+ ]
+ );
+
const handleOpenAgentChatView = useCallback(() => {
if (!activeAgent) {
return;
@@ -959,143 +1015,223 @@ function WorkspaceScreenContent({
return (
-
-
-
- {headerTitle}
-
- >
- }
- right={
-
- {isMobile ? (
+
+
+
+
+
+ {headerTitle}
+
+ >
+ }
+ right={
+
+
+ {isMobile ? (
+ isGitCheckout ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ {activeAgent ? (
+
+
+
+
+
+
+ Open chat view
+
+
+
+ ) : null}
+
+ }
+ />
+
+ {isMobile ? (
+
[
styles.switcherTrigger,
- (hovered || pressed) && styles.switcherTriggerActive,
+ (hovered || pressed || isTabSwitcherOpen) && styles.switcherTriggerActive,
+ { borderWidth: 0, borderColor: "transparent" },
+ Platform.OS === "web"
+ ? {
+ outlineStyle: "solid",
+ outlineWidth: 0,
+ outlineColor: "transparent",
+ }
+ : null,
]}
onPress={() => setIsTabSwitcherOpen(true)}
>
-
- {activeTabLabel}
-
+
+
+ {(() => {
+ const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null;
+ if (!activeDescriptor) {
+ return ;
+ }
+
+ if (activeDescriptor.kind === "terminal") {
+ return ;
+ }
+
+ const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
+ const tabAgentStatusBucket = tabAgent
+ ? deriveSidebarStateBucket({
+ status: tabAgent.status,
+ pendingPermissionCount: tabAgent.pendingPermissions.length,
+ requiresAttention: tabAgent.requiresAttention,
+ attentionReason: tabAgent.attentionReason,
+ })
+ : null;
+ const tabAgentStatusColor =
+ tabAgentStatusBucket === null
+ ? null
+ : getStatusDotColor({
+ theme,
+ bucket: tabAgentStatusBucket,
+ showDoneAsInactive: false,
+ });
+
+ return (
+
+ {activeDescriptor.provider === "claude" ? (
+
+ ) : activeDescriptor.provider === "codex" ? (
+
+ ) : (
+
+ )}
+ {tabAgentStatusColor ? (
+
+ ) : null}
+
+ );
+ })()}
+
+
+
+ {activeTabLabel}
+
+
+
- ) : null}
-
- {isMobile ? (
- isGitCheckout ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
-
- [
- styles.newTabButton,
- (hovered || pressed || open) && styles.newTabButtonActive,
- ]}
- >
-
- New tab
-
-
-
- {
- handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID);
- }}
- >
- Agent tab
-
- {
- handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID);
- }}
- >
- Terminal tab
-
-
-
-
- {activeAgent ? (
-
-
-
-
-
-
+
+ handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
+ accessibilityRole="button"
+ accessibilityLabel="New agent tab"
+ style={({ hovered, pressed }) => [
+ styles.newTabActionButton,
+ (hovered || pressed) && styles.newTabActionButtonHovered,
+ ]}
>
- Open chat view
-
-
-
- ) : null}
+
+
+
+ New agent tab
+
+
+
+
+ handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
+ disabled={createTerminalMutation.isPending}
+ accessibilityRole="button"
+ accessibilityLabel="New terminal tab"
+ style={({ hovered, pressed }) => [
+ styles.newTabActionButton,
+ createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
+ (hovered || pressed) && styles.newTabActionButtonHovered,
+ ]}
+ >
+ {createTerminalMutation.isPending ? (
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ New terminal tab
+
+
+
- {isMobile ? (
- ) : null}
-
- }
- />
-
- {!isMobile ? (
-
-
- {tabs.map((tab) => {
- const isActive = tab.key === activeTabKey;
- const isTabHovered = hoveredTabKey === tab.key;
- const isCloseHovered = hoveredCloseTabKey === tab.key;
- const isClosingTerminal =
- tab.kind === "terminal" &&
- killTerminalMutation.isPending &&
- killTerminalMutation.variables === tab.terminalId;
- const shouldShowCloseButton =
- tab.kind === "terminal" &&
- (isTabHovered || isCloseHovered || isClosingTerminal);
- const iconColor = isActive
- ? theme.colors.foreground
- : theme.colors.foregroundMuted;
- const icon =
- tab.kind === "agent" ? (
- tab.provider === "claude" ? (
-
- ) : tab.provider === "codex" ? (
-
- ) : (
-
- )
- ) : (
-
- );
-
- return (
- [
- styles.tab,
- isActive && styles.tabActive,
- (hovered || pressed) && styles.tabHovered,
- ]}
- onHoverIn={() => {
- setHoveredTabKey(tab.key);
- }}
- onHoverOut={() => {
- setHoveredTabKey((current) =>
- current === tab.key ? null : current
- );
- }}
- onPress={() => {
- if (tab.kind === "agent") {
- navigateToTab({ kind: "agent", agentId: tab.agentId });
- return;
- }
- navigateToTab({
- kind: "terminal",
- terminalId: tab.terminalId,
+
+ ) : (
+
+
+ {tabs.map((tab) => {
+ const isActive = tab.key === activeTabKey;
+ const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
+ const isTabHovered = hoveredTabKey === tab.key;
+ const isCloseHovered = hoveredCloseTabKey === tab.key;
+ const isClosingAgent =
+ tab.kind === "agent" &&
+ isArchivingAgent({
+ serverId: normalizedServerId,
+ agentId: tab.agentId,
});
- }}
- >
- {icon}
-
- {tab.label}
-
- {tab.kind === "terminal" ? (
+ const isClosingTerminal =
+ tab.kind === "terminal" &&
+ killTerminalMutation.isPending &&
+ killTerminalMutation.variables === tab.terminalId;
+ const isClosingTab = isClosingAgent || isClosingTerminal;
+ const shouldShowCloseButton = true;
+ const iconColor = isActive
+ ? theme.colors.foreground
+ : theme.colors.foregroundMuted;
+ const tabAgentStatusBucket = tabAgent
+ ? deriveSidebarStateBucket({
+ status: tabAgent.status,
+ pendingPermissionCount: tabAgent.pendingPermissions.length,
+ requiresAttention: tabAgent.requiresAttention,
+ attentionReason: tabAgent.attentionReason,
+ })
+ : null;
+ const tabAgentStatusColor =
+ tabAgentStatusBucket === null
+ ? null
+ : getStatusDotColor({
+ theme,
+ bucket: tabAgentStatusBucket,
+ showDoneAsInactive: false,
+ });
+ const icon =
+ tab.kind === "agent" ? (
+
+ {tab.provider === "claude" ? (
+
+ ) : tab.provider === "codex" ? (
+
+ ) : (
+
+ )}
+ {tabAgentStatusColor ? (
+
+ ) : null}
+
+ ) : (
+
+ );
+
+ return (
[
+ styles.tab,
+ isActive && styles.tabActive,
+ (hovered || pressed || isCloseHovered) && styles.tabHovered,
+ ]}
onHoverIn={() => {
- setHoveredCloseTabKey(tab.key);
+ setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
- setHoveredCloseTabKey((current) =>
+ setHoveredTabKey((current) =>
current === tab.key ? null : current
);
}}
- onPress={(event) => {
- event.stopPropagation();
- void handleCloseTerminalTab(tab.terminalId);
+ onPress={() => {
+ if (tab.kind === "agent") {
+ navigateToTab({ kind: "agent", agentId: tab.agentId });
+ return;
+ }
+ navigateToTab({
+ kind: "terminal",
+ terminalId: tab.terminalId,
+ });
}}
- style={({ hovered, pressed }) => [
- styles.tabCloseButton,
- shouldShowCloseButton
- ? styles.tabCloseButtonShown
- : styles.tabCloseButtonHidden,
- (hovered || pressed) && styles.tabCloseButtonActive,
- ]}
>
- {isClosingTerminal ? (
-
- ) : (
-
- )}
+ {icon}
+
+ {tab.label}
+
+ {
+ setHoveredTabKey(tab.key);
+ setHoveredCloseTabKey(tab.key);
+ }}
+ onHoverOut={() => {
+ setHoveredTabKey((current) =>
+ current === tab.key ? null : current
+ );
+ setHoveredCloseTabKey((current) =>
+ current === tab.key ? null : current
+ );
+ }}
+ onPress={(event) => {
+ event.stopPropagation?.();
+ if (tab.kind === "agent") {
+ void handleCloseAgentTab(tab.agentId);
+ return;
+ }
+ void handleCloseTerminalTab(tab.terminalId);
+ }}
+ style={({ hovered, pressed }) => [
+ styles.tabCloseButton,
+ shouldShowCloseButton
+ ? styles.tabCloseButtonShown
+ : styles.tabCloseButtonHidden,
+ (hovered || pressed) && styles.tabCloseButtonActive,
+ ]}
+ >
+ {isClosingTab ? (
+
+ ) : (
+
+ )}
+
- ) : null}
-
- );
- })}
-
-
- ) : null}
+ );
+ })}
+
+
+
+ handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
+ accessibilityRole="button"
+ accessibilityLabel="New agent tab"
+ style={({ hovered, pressed }) => [
+ styles.newTabActionButton,
+ (hovered || pressed) && styles.newTabActionButtonHovered,
+ ]}
+ >
+
+
+
+ New agent tab
+
+
+
+ handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
+ disabled={createTerminalMutation.isPending}
+ accessibilityRole="button"
+ accessibilityLabel="New terminal tab"
+ style={({ hovered, pressed }) => [
+ styles.newTabActionButton,
+ createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
+ (hovered || pressed) && styles.newTabActionButtonHovered,
+ ]}
+ >
+ {createTerminalMutation.isPending ? (
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ New terminal tab
+
+
+
+
+ )}
-
- {isMobile ? (
-
- {renderContent()}
-
- ) : (
- {renderContent()}
- )}
+
+ {isMobile ? (
+
+ {renderContent()}
+
+ ) : (
+ {renderContent()}
+ )}
+
+
({
flex: 1,
backgroundColor: theme.colors.surface0,
},
+ threePaneRow: {
+ flex: 1,
+ minHeight: 0,
+ flexDirection: "row",
+ alignItems: "stretch",
+ },
+ centerColumn: {
+ flex: 1,
+ minHeight: 0,
+ },
headerTitle: {
flex: 1,
fontSize: theme.fontSize.base,
@@ -1268,50 +1514,108 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
- newTabButton: {
+ newTabActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
- paddingHorizontal: theme.spacing[2],
- paddingVertical: theme.spacing[1],
+ },
+ newTabActionButton: {
+ width: 30,
+ height: 30,
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
+ backgroundColor: theme.colors.surface1,
+ alignItems: "center",
+ justifyContent: "center",
},
- newTabButtonActive: {
+ newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
- newTabButtonText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
+ newTabActionButtonDisabled: {
+ opacity: 0.6,
},
- switcherTrigger: {
- maxWidth: 220,
+ newTabTooltipText: {
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.popoverForeground,
+ },
+ terminalPlusIcon: {
+ position: "relative",
+ width: theme.iconSize.sm,
+ height: theme.iconSize.sm,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ terminalPlusBadge: {
+ position: "absolute",
+ right: -6,
+ bottom: -6,
+ width: 14,
+ height: 14,
+ borderRadius: 7,
+ backgroundColor: theme.colors.surface1,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ mobileTabsRow: {
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ backgroundColor: theme.colors.surface0,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingHorizontal: theme.spacing[2],
+ paddingVertical: theme.spacing[1],
+ },
+ mobileTabsActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
+ },
+ switcherTrigger: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ flex: 1,
+ minWidth: 0,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
+ justifyContent: "space-between",
},
switcherTriggerActive: {
backgroundColor: theme.colors.surface2,
},
+ switcherTriggerLeft: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ flex: 1,
+ minWidth: 0,
+ },
+ switcherTriggerIcon: {
+ flexShrink: 0,
+ },
switcherTriggerText: {
minWidth: 0,
- flexShrink: 1,
- color: theme.colors.foregroundMuted,
+ flex: 1,
+ color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
tabsContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
+ flexDirection: "row",
+ alignItems: "center",
},
tabsScroll: {
flex: 1,
+ minWidth: 0,
},
tabsContent: {
flexDirection: "row",
@@ -1320,10 +1624,16 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
- mainRow: {
+ tabsActions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ paddingRight: theme.spacing[2],
+ paddingVertical: theme.spacing[1],
+ },
+ centerContent: {
flex: 1,
minHeight: 0,
- flexDirection: "row",
},
tab: {
paddingHorizontal: theme.spacing[3],
@@ -1337,6 +1647,22 @@ const styles = StyleSheet.create((theme) => ({
tabIcon: {
flexShrink: 0,
},
+ tabAgentIconWrapper: {
+ position: "relative",
+ width: 14,
+ height: 14,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ tabStatusDot: {
+ position: "absolute",
+ right: -2,
+ bottom: -2,
+ width: 7,
+ height: 7,
+ borderRadius: theme.borderRadius.full,
+ borderWidth: 1,
+ },
tabActive: {
backgroundColor: theme.colors.surface2,
},
@@ -1351,7 +1677,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
tabLabelWithCloseButton: {
- paddingRight: theme.spacing[1],
+ paddingRight: 0,
},
tabLabelActive: {
color: theme.colors.foreground,
@@ -1359,7 +1685,7 @@ const styles = StyleSheet.create((theme) => ({
tabCloseButton: {
width: 18,
height: 18,
- marginLeft: theme.spacing[1],
+ marginLeft: 0,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
diff --git a/packages/app/src/utils/sidebar-agent-state.test.ts b/packages/app/src/utils/sidebar-agent-state.test.ts
index 1377aa1c0..8e9ae735e 100644
--- a/packages/app/src/utils/sidebar-agent-state.test.ts
+++ b/packages/app/src/utils/sidebar-agent-state.test.ts
@@ -34,4 +34,15 @@ describe("deriveSidebarStateBucket", () => {
})
).toBe("attention");
});
+
+ it("treats initializing agents as running", () => {
+ expect(
+ deriveSidebarStateBucket({
+ status: "initializing",
+ pendingPermissionCount: 0,
+ requiresAttention: false,
+ attentionReason: null,
+ })
+ ).toBe("running");
+ });
});
diff --git a/packages/app/src/utils/sidebar-agent-state.ts b/packages/app/src/utils/sidebar-agent-state.ts
index 627e63339..fe0739702 100644
--- a/packages/app/src/utils/sidebar-agent-state.ts
+++ b/packages/app/src/utils/sidebar-agent-state.ts
@@ -31,7 +31,7 @@ export function deriveSidebarStateBucket(input: {
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
- if (input.status === "running") {
+ if (input.status === "running" || input.status === "initializing") {
return "running";
}
if (input.requiresAttention) {
diff --git a/packages/app/src/utils/status-dot-color.ts b/packages/app/src/utils/status-dot-color.ts
new file mode 100644
index 000000000..22c24d4bd
--- /dev/null
+++ b/packages/app/src/utils/status-dot-color.ts
@@ -0,0 +1,27 @@
+import type { Theme } from "@/styles/theme";
+import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
+
+export function getStatusDotColor(input: {
+ theme: Theme;
+ bucket: SidebarStateBucket;
+ showDoneAsInactive?: boolean;
+}): string | null {
+ const { theme, bucket, showDoneAsInactive = false } = input;
+
+ if (bucket === "needs_input") {
+ return theme.colors.palette.amber[500];
+ }
+ if (bucket === "failed") {
+ return theme.colors.palette.red[500];
+ }
+ if (bucket === "running") {
+ return theme.colors.palette.blue[500];
+ }
+ if (bucket === "attention") {
+ return theme.colors.palette.green[500];
+ }
+ if (bucket === "done") {
+ return showDoneAsInactive ? theme.colors.border : null;
+ }
+ return null;
+}
diff --git a/packages/cli/cli-client-id b/packages/cli/cli-client-id
new file mode 100644
index 000000000..82dd3d767
--- /dev/null
+++ b/packages/cli/cli-client-id
@@ -0,0 +1 @@
+cid_48610cdfae94492497dcf8d77214267c
\ No newline at end of file
diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts
index 95f6b20dc..416c18618 100644
--- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts
+++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts
@@ -383,8 +383,7 @@ const shouldRun = !process.env.CI;
await waitForCondition(() => sawExit, 10000);
const next = await ctx.client.listTerminals(cwd);
- expect(next.terminals).toHaveLength(1);
- expect(next.terminals[0].id).not.toBe(terminalId);
+ expect(next.terminals).toHaveLength(0);
unsubscribeExit();
rmSync(cwd, { recursive: true, force: true });
diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts
index b4864d013..12335394c 100644
--- a/packages/server/src/terminal/terminal-manager.test.ts
+++ b/packages/server/src/terminal/terminal-manager.test.ts
@@ -200,13 +200,15 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(id)).toBeUndefined();
});
- it("removes cwd entry when last terminal is killed", async () => {
+ it("keeps cwd entry when last terminal is killed (but does not auto-recreate)", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
- expect(manager.listDirectories()).not.toContain("/tmp");
+ expect(manager.listDirectories()).toContain("/tmp");
+ const remaining = await manager.getTerminals("/tmp");
+ expect(remaining).toHaveLength(0);
});
it("keeps cwd entry when other terminals remain", async () => {
@@ -239,8 +241,7 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
- expect(remaining).toHaveLength(1);
- expect(remaining[0].id).not.toBe(exitedId);
+ expect(remaining).toHaveLength(0);
});
});
@@ -250,7 +251,7 @@ describe("TerminalManager", () => {
expect(manager.listDirectories()).toEqual([]);
});
- it("returns all cwds with active terminals", async () => {
+ it("returns all cwds that have ever had terminals", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
await manager.getTerminals("/home");
diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts
index e336130f7..a30ee697a 100644
--- a/packages/server/src/terminal/terminal-manager.ts
+++ b/packages/server/src/terminal/terminal-manager.ts
@@ -35,6 +35,7 @@ export function createTerminalManager(): TerminalManager {
const terminalExitUnsubscribeById = new Map void>();
const terminalsChangedListeners = new Set();
const defaultEnvByRootCwd = new Map>();
+ const knownDirectories = new Set();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
@@ -134,8 +135,12 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise {
assertAbsolutePath(cwd);
- let terminals = terminalsByCwd.get(cwd);
- if (!terminals || terminals.length === 0) {
+ const terminals = terminalsByCwd.get(cwd);
+ if (terminals && terminals.length > 0) {
+ return terminals;
+ }
+
+ if (!knownDirectories.has(cwd)) {
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
const session = registerSession(
await createTerminal({
@@ -144,11 +149,14 @@ export function createTerminalManager(): TerminalManager {
...(inheritedEnv ? { env: inheritedEnv } : {}),
})
);
- terminals = [session];
- terminalsByCwd.set(cwd, terminals);
+ const created = [session];
+ terminalsByCwd.set(cwd, created);
+ knownDirectories.add(cwd);
emitTerminalsChanged({ cwd });
+ return created;
}
- return terminals;
+
+ return [];
},
async createTerminal(options: {
@@ -158,6 +166,7 @@ export function createTerminalManager(): TerminalManager {
}): Promise {
assertAbsolutePath(options.cwd);
+ knownDirectories.add(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? [];
const defaultName = `Terminal ${terminals.length + 1}`;
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
@@ -194,13 +203,14 @@ export function createTerminalManager(): TerminalManager {
},
listDirectories(): string[] {
- return Array.from(terminalsByCwd.keys());
+ return Array.from(knownDirectories);
},
killAll(): void {
for (const id of Array.from(terminalsById.keys())) {
removeSessionById(id, { kill: true });
}
+ knownDirectories.clear();
},
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {