From c8cef9bec82584ded2b61d0895bf219e2eaf01ce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Mar 2026 12:23:04 +0700 Subject: [PATCH] Restore workspace header controls and polish tab management --- .../e2e/workspace-header-tabs-restore.spec.ts | 77 +- .../app/src/components/agent-input-area.tsx | 14 +- .../app/src/components/agent-status-dot.tsx | 36 +- .../src/components/draggable-list.native.tsx | 8 +- .../src/components/draggable-list.types.ts | 20 + .../app/src/components/draggable-list.web.tsx | 145 ++- .../app/src/components/explorer-sidebar.tsx | 34 +- packages/app/src/components/left-sidebar.tsx | 49 +- .../app/src/components/sidebar-agent-list.tsx | 93 +- packages/app/src/components/terminal-pane.tsx | 69 +- .../app/src/hooks/use-keyboard-shift-style.ts | 84 ++ .../app/src/hooks/use-sidebar-agents-list.ts | 5 + .../src/screens/agent/agent-ready-screen.tsx | 23 +- .../src/screens/agent/draft-agent-screen.tsx | 20 +- .../screens/workspace/workspace-screen.tsx | 882 ++++++++++++------ .../app/src/utils/sidebar-agent-state.test.ts | 11 + packages/app/src/utils/sidebar-agent-state.ts | 2 +- packages/app/src/utils/status-dot-color.ts | 27 + packages/cli/cli-client-id | 1 + .../server/daemon-e2e/terminal.e2e.test.ts | 3 +- .../src/terminal/terminal-manager.test.ts | 11 +- .../server/src/terminal/terminal-manager.ts | 22 +- 22 files changed, 1132 insertions(+), 504 deletions(-) create mode 100644 packages/app/src/hooks/use-keyboard-shift-style.ts create mode 100644 packages/app/src/utils/status-dot-color.ts create mode 100644 packages/cli/cli-client-id diff --git a/packages/app/e2e/workspace-header-tabs-restore.spec.ts b/packages/app/e2e/workspace-header-tabs-restore.spec.ts index 3c835d4b8..45c95e0ab 100644 --- a/packages/app/e2e/workspace-header-tabs-restore.spec.ts +++ b/packages/app/e2e/workspace-header-tabs-restore.spec.ts @@ -26,38 +26,87 @@ async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promis await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), { timeout: 30000, }); - await expect(page.getByTestId("workspace-new-tab").first()).toBeVisible({ + await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({ timeout: 30000, }); } -test("workspace new-tab menu opens on-screen", async ({ page }) => { +test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => { const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-"); try { await openWorkspaceWithAgent(page, repo.path); - const trigger = page.getByTestId("workspace-new-tab").first(); - await expect(trigger).toBeVisible({ timeout: 30000 }); - await trigger.click(); + const agentButton = page.getByTestId("workspace-new-agent-tab").first(); + const terminalButton = page.getByTestId("workspace-new-terminal-tab").first(); + const tabsScroll = page.getByTestId("workspace-tabs-scroll").first(); + await expect(agentButton).toBeVisible({ timeout: 30000 }); + await expect(terminalButton).toBeVisible({ timeout: 30000 }); + await expect(tabsScroll).toBeVisible({ timeout: 30000 }); - const menu = page.getByTestId("workspace-new-tab-content").first(); - await expect(menu).toBeVisible({ timeout: 10000 }); + // Create enough terminal tabs to ensure the tabs row has overflow to scroll. + const terminalTabs = page.locator('[data-testid^="workspace-tab-terminal:"]'); + const initialTerminalCount = await terminalTabs.count(); + const targetTerminalCount = initialTerminalCount + 8; - const menuBounds = await menu.boundingBox(); + for (let attempt = initialTerminalCount; attempt < targetTerminalCount; attempt += 1) { + await expect(terminalButton).toBeEnabled({ timeout: 30000 }); + await terminalButton.click(); + await expect + .poll(async () => await terminalTabs.count(), { timeout: 30000 }) + .toBeGreaterThanOrEqual(attempt + 1); + } + + const agentBoundsBefore = await agentButton.boundingBox(); + const terminalBoundsBefore = await terminalButton.boundingBox(); const viewport = page.viewportSize(); - expect(menuBounds).not.toBeNull(); + expect(agentBoundsBefore).not.toBeNull(); + expect(terminalBoundsBefore).not.toBeNull(); expect(viewport).not.toBeNull(); - if (!menuBounds || !viewport) { + if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) { return; } - expect(menuBounds.x).toBeGreaterThanOrEqual(0); - expect(menuBounds.y).toBeGreaterThanOrEqual(0); - expect(menuBounds.x + menuBounds.width).toBeLessThanOrEqual(viewport.width); - expect(menuBounds.y + menuBounds.height).toBeLessThanOrEqual(viewport.height); + expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0); + expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0); + expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width); + expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height); + + expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0); + expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0); + expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width); + expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height); + + // Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge. + await tabsScroll.evaluate((el) => { + (el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth; + }); + await page.waitForTimeout(200); + + const agentBoundsAfter = await agentButton.boundingBox(); + const terminalBoundsAfter = await terminalButton.boundingBox(); + + expect(agentBoundsAfter).not.toBeNull(); + expect(terminalBoundsAfter).not.toBeNull(); + + if (!agentBoundsAfter || !terminalBoundsAfter) { + return; + } + + expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0); + expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0); + expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width); + expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height); + + expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0); + expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0); + expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width); + expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height); } finally { await repo.cleanup(); } diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6f0557024..7225592c9 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -2,8 +2,7 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native import { useState, useEffect, useRef, useCallback } from 'react' import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native' -import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' +import Animated from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useIsFocused } from '@react-navigation/native' import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' @@ -37,6 +36,7 @@ import { } from '@/attachments/service' import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard' import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation' +import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' type QueuedMessage = { id: string @@ -84,7 +84,6 @@ export function AgentInputArea({ markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) const { theme } = useUnistyles() const insets = useSafeAreaInsets() - const { height: keyboardHeight } = useReanimatedKeyboardAnimation() const isScreenFocused = useIsFocused() const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest) const clearMessageInputActionRequest = useKeyboardShortcutsStore( @@ -551,13 +550,8 @@ export function AgentInputArea({ serverId, ]) - const keyboardAnimatedStyle = useAnimatedStyle(() => { - 'worklet' - const absoluteHeight = Math.abs(keyboardHeight.value) - const shift = Math.max(0, absoluteHeight - insets.bottom) - return { - transform: [{ translateY: -shift }], - } + const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ + mode: 'translate', }) function handleCancelAgent() { diff --git a/packages/app/src/components/agent-status-dot.tsx b/packages/app/src/components/agent-status-dot.tsx index 93309a3b5..37ae7a67e 100644 --- a/packages/app/src/components/agent-status-dot.tsx +++ b/packages/app/src/components/agent-status-dot.tsx @@ -1,25 +1,41 @@ import { View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + AGENT_LIFECYCLE_STATUSES, + type AgentLifecycleStatus, +} from "@server/shared/agent-lifecycle"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { getStatusDotColor } from "@/utils/status-dot-color"; export function AgentStatusDot({ status, requiresAttention, + attentionReason, + pendingPermissionCount, showInactive = false, }: { status: string | null | undefined; requiresAttention: boolean | null | undefined; + attentionReason?: "finished" | "error" | "permission" | null; + pendingPermissionCount?: number; showInactive?: boolean; }) { const { theme } = useUnistyles(); - const isRunning = status === "running"; - const color = isRunning - ? theme.colors.palette.blue[500] - : requiresAttention - ? theme.colors.success - : showInactive - ? theme.colors.border - : null; + if (!status) { + return null; + } + if (!isAgentLifecycleStatus(status)) { + return null; + } + + const bucket = deriveSidebarStateBucket({ + status, + requiresAttention: Boolean(requiresAttention), + attentionReason: attentionReason ?? null, + pendingPermissionCount: pendingPermissionCount ?? 0, + }); + const color = getStatusDotColor({ theme, bucket, showDoneAsInactive: showInactive }); if (!color) { return null; @@ -28,6 +44,10 @@ export function AgentStatusDot({ return ; } +function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus { + return AGENT_LIFECYCLE_STATUSES.some((status) => status === value); +} + const styles = StyleSheet.create((theme) => ({ dot: { width: 8, diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 42a478ed8..61d37ebb1 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -17,6 +17,7 @@ export function DraggableList({ renderItem, onDragEnd, style, + containerStyle, contentContainerStyle, testID, ListFooterComponent, @@ -24,6 +25,8 @@ export function DraggableList({ ListEmptyComponent, showsVerticalScrollIndicator = true, enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false, + scrollEnabled = true, + useDragHandle: _useDragHandle = false, refreshing, onRefresh, simultaneousGestureRef, @@ -70,6 +73,8 @@ export function DraggableList({ }, []); const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing)); + const resolvedContainerStyle = + containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined); return ( ({ renderItem={handleRenderItem} onDragEnd={handleDragEnd} style={style} - containerStyle={{ flex: 1 }} + containerStyle={resolvedContainerStyle} contentContainerStyle={contentContainerStyle} ListFooterComponent={ListFooterComponent} ListHeaderComponent={ListHeaderComponent} ListEmptyComponent={ListEmptyComponent} showsVerticalScrollIndicator={showsVerticalScrollIndicator} + scrollEnabled={scrollEnabled} simultaneousHandlers={simultaneousHandlers} // Higher activationDistance prevents drag from interfering with nested onLongPress handlers activationDistance={20} diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index 043f2e5c8..f09d5ec63 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -2,11 +2,22 @@ import type { ReactElement, MutableRefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { GestureType } from "react-native-gesture-handler"; +export interface DraggableListDragHandleProps { + /** + * Web-only drag handle props (from dnd-kit). Spread these onto the element + * that should initiate the drag. Native uses the `drag()` callback instead. + */ + attributes?: Record; + listeners?: Record; + setActivatorNodeRef?: (node: unknown) => void; +} + export interface DraggableRenderItemInfo { item: T; index: number; drag: () => void; isActive: boolean; + dragHandleProps?: DraggableListDragHandleProps; } export interface DraggableListProps { @@ -15,6 +26,8 @@ export interface DraggableListProps { renderItem: (info: DraggableRenderItemInfo) => ReactElement; onDragEnd: (data: T[]) => void; style?: StyleProp; + /** Outer container style (useful for nested, non-scrolling lists). */ + containerStyle?: StyleProp; contentContainerStyle?: StyleProp; testID?: string; ListFooterComponent?: ReactElement | null; @@ -22,6 +35,13 @@ export interface DraggableListProps { ListEmptyComponent?: ReactElement | null; showsVerticalScrollIndicator?: boolean; enableDesktopWebScrollbar?: boolean; + /** When false, disables internal scrolling (use outer list to scroll). */ + scrollEnabled?: boolean; + /** + * Web-only: when true, the drag can only be initiated from the handle props + * passed to `renderItem` (prevents nested lists from fighting). + */ + useDragHandle?: boolean; refreshing?: boolean; onRefresh?: () => void; /** Fill remaining space when content is smaller than container */ diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 14195a326..e259e9f14 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -41,6 +41,7 @@ interface SortableItemProps { index: number; renderItem: (info: DraggableRenderItemInfo) => ReactElement; activeId: string | null; + useDragHandle: boolean; } function SortableItem({ @@ -49,11 +50,13 @@ function SortableItem({ index, renderItem, activeId, + useDragHandle, }: SortableItemProps) { const { attributes, listeners, setNodeRef, + setActivatorNodeRef, transform, transition, isDragging, @@ -87,10 +90,23 @@ function SortableItem({ index, drag, isActive: activeId === id, + dragHandleProps: useDragHandle + ? { + attributes: attributes as unknown as Record, + listeners: listeners as unknown as Record, + setActivatorNodeRef: setActivatorNodeRef as unknown as ( + node: unknown + ) => void, + } + : undefined, }; + const wrapperProps = useDragHandle + ? { ref: setNodeRef } + : { ref: setNodeRef, ...attributes, ...listeners }; + return ( -
+
{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 {