From 223c8d7d0bbd8f2098f57aee4f8128fa3728269e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Mar 2026 15:59:13 +0700 Subject: [PATCH] feat: sortable workspace tabs, tab context menus, and terminal auto-create removal - Add drag-and-drop tab reordering with persistent tab order - Add right-click context menu with copy agent ID, copy resume command, close to right - Switch workspace route encoding from percent-encoding to base64url - Remove terminal auto-creation on directory listing (explicit create only) - Add swipe gestures on terminal emulator for mobile sidebar navigation - Highlight active workspace row in sidebar --- .gitignore | 2 + .../e2e/workspace-header-tabs-restore.spec.ts | 3 +- .../app/src/components/sidebar-agent-list.tsx | 40 +- .../sortable-inline-list.native.tsx | 44 ++ .../src/components/sortable-inline-list.tsx | 8 + .../components/sortable-inline-list.web.tsx | 206 +++++ .../app/src/components/terminal-emulator.tsx | 123 +++ packages/app/src/components/terminal-pane.tsx | 18 + .../app/src/components/ui/context-menu.tsx | 707 ++++++++++++++++++ .../app/src/hooks/use-sidebar-agents-list.ts | 5 +- .../screens/workspace/workspace-screen.tsx | 591 +++++++++++---- .../app/src/stores/workspace-tabs-store.ts | 69 +- packages/app/src/utils/host-routes.test.ts | 32 +- packages/app/src/utils/host-routes.ts | 217 +++++- .../src/utils/provider-command-templates.ts | 40 + .../server/daemon-e2e/terminal.e2e.test.ts | 53 +- packages/server/src/server/session.ts | 7 - .../src/terminal/terminal-manager.test.ts | 63 +- .../server/src/terminal/terminal-manager.ts | 28 +- 19 files changed, 1971 insertions(+), 285 deletions(-) create mode 100644 packages/app/src/components/sortable-inline-list.native.tsx create mode 100644 packages/app/src/components/sortable-inline-list.tsx create mode 100644 packages/app/src/components/sortable-inline-list.web.tsx create mode 100644 packages/app/src/components/ui/context-menu.tsx create mode 100644 packages/app/src/utils/provider-command-templates.ts diff --git a/.gitignore b/.gitignore index 29bca2a18..fffee8989 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ valknut-report.json/ .plans/ packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt + +/artifacts diff --git a/packages/app/e2e/workspace-header-tabs-restore.spec.ts b/packages/app/e2e/workspace-header-tabs-restore.spec.ts index 45c95e0ab..a54a3288b 100644 --- a/packages/app/e2e/workspace-header-tabs-restore.spec.ts +++ b/packages/app/e2e/workspace-header-tabs-restore.spec.ts @@ -6,9 +6,10 @@ import { setWorkingDirectory, } from "./helpers/app"; import { createTempGitRepo } from "./helpers/workspace"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; function buildWorkspaceRoute(serverId: string, workspacePath: string): string { - return `/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent(workspacePath)}`; + return buildHostWorkspaceRoute(serverId, workspacePath); } async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise { diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index 71baed489..e1e13f3fc 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -9,14 +9,19 @@ import { type ReactElement, type MutableRefObject, } from 'react' -import { router, useSegments } from 'expo-router' +import { router, usePathname, 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 } from '@/utils/host-routes' +import { + buildHostWorkspaceRoute, + parseHostWorkspaceAgentRouteFromPathname, + parseHostWorkspaceRouteFromPathname, + parseHostWorkspaceTerminalRouteFromPathname, +} from '@/utils/host-routes' import { type SidebarProjectEntry, type SidebarWorkspaceEntry, @@ -69,6 +74,7 @@ interface ProjectRowProps { interface WorkspaceRowProps { workspace: SidebarWorkspaceEntry + selected: boolean onPress: () => void onLongPress: () => void } @@ -195,7 +201,7 @@ function ProjectRow({ ) } -function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) { +function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) { const didLongPressRef = useRef(false) const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) @@ -216,6 +222,7 @@ function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) { [ styles.workspaceRow, + selected && styles.workspaceRowSelected, hovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} @@ -274,6 +281,7 @@ export function SidebarAgentList({ const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile const segments = useSegments() + const pathname = usePathname() const shouldReplaceWorkspaceNavigation = segments[0] === 'h' const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0) @@ -283,6 +291,23 @@ export function SidebarAgentList({ const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder) const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder) + const activeWorkspaceSelection = useMemo(() => { + if (!pathname) { + return null + } + const parsed = + parseHostWorkspaceAgentRouteFromPathname(pathname) ?? + parseHostWorkspaceTerminalRouteFromPathname(pathname) ?? + parseHostWorkspaceRouteFromPathname(pathname) + if (!parsed) { + return null + } + return { + serverId: parsed.serverId, + workspaceId: parsed.workspaceId, + } + }, [pathname]) + useEffect(() => { setCollapsedProjectKeys((prev) => { const validProjectKeys = new Set(projects.map((project) => project.projectKey)) @@ -421,10 +446,15 @@ export function SidebarAgentList({ const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd) const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push + const isSelected = + Boolean(serverId) && + activeWorkspaceSelection?.serverId === serverId && + activeWorkspaceSelection.workspaceId === item.workspace.cwd return ( { if (!serverId) { return @@ -437,6 +467,7 @@ export function SidebarAgentList({ ) }, [ + activeWorkspaceSelection, collapsedProjectKeys, isMobile, onWorkspacePress, @@ -632,6 +663,9 @@ const styles = StyleSheet.create((theme) => ({ workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, + workspaceRowSelected: { + backgroundColor: theme.colors.surface2, + }, workspaceStatusDot: { width: 8, height: 8, diff --git a/packages/app/src/components/sortable-inline-list.native.tsx b/packages/app/src/components/sortable-inline-list.native.tsx new file mode 100644 index 000000000..32099a6c4 --- /dev/null +++ b/packages/app/src/components/sortable-inline-list.native.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from "react"; +import type { DraggableRenderItemInfo } from "./draggable-list.types"; + +export function SortableInlineList({ + data, + keyExtractor, + renderItem, +}: { + data: T[]; + keyExtractor: (item: T, index: number) => string; + renderItem: (info: DraggableRenderItemInfo) => ReactElement; + onDragEnd?: (data: T[]) => void; + useDragHandle?: boolean; + disabled?: boolean; +}): ReactElement { + return ( + <> + {data.map((item, index) => { + const id = keyExtractor(item, index); + return ( + + ); + })} + + ); +} + +function SortableInlineListItem({ + item, + index, + renderItem, +}: { + item: T; + index: number; + renderItem: (info: DraggableRenderItemInfo) => ReactElement; +}): ReactElement { + const info: DraggableRenderItemInfo = { + item, + index, + drag: () => {}, + isActive: false, + }; + return renderItem(info); +} diff --git a/packages/app/src/components/sortable-inline-list.tsx b/packages/app/src/components/sortable-inline-list.tsx new file mode 100644 index 000000000..6e2b2316e --- /dev/null +++ b/packages/app/src/components/sortable-inline-list.tsx @@ -0,0 +1,8 @@ +// This file exists for TypeScript resolution. +// The actual implementations are in: +// - sortable-inline-list.native.tsx (iOS/Android) +// - sortable-inline-list.web.tsx (Web) +// Metro's platform-specific extensions will pick the right one at runtime. + +export * from "./sortable-inline-list.native"; + diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx new file mode 100644 index 000000000..8405a61be --- /dev/null +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useState, type ReactElement } from "react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + type Modifier, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + horizontalListSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { DraggableRenderItemInfo } from "./draggable-list.types"; + +const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ + ...transform, + y: 0, +}); + +function SortableItem({ + id, + item, + index, + renderItem, + activeId, + useDragHandle, + disabled, +}: { + id: string; + item: T; + index: number; + renderItem: (info: DraggableRenderItemInfo) => ReactElement; + activeId: string | null; + useDragHandle: boolean; + disabled: boolean; +}): ReactElement { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }); + + const drag = useCallback(() => { + // dnd-kit handles drag initiation via listeners + // This is a no-op but matches the mobile API + }, []); + + const baseTransform = CSS.Transform.toString(transform); + const scaleTransform = isDragging ? "scale(1.01)" : ""; + const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); + + const style = { + transform: combinedTransform || undefined, + transition, + opacity: isDragging ? 0.9 : 1, + zIndex: isDragging ? 1000 : 1, + }; + + const info: DraggableRenderItemInfo = { + item, + 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)} +
+ ); +} + +export function SortableInlineList({ + data, + keyExtractor, + renderItem, + onDragEnd, + useDragHandle = false, + disabled = false, + activationDistance = 8, + onDragBegin, +}: { + data: T[]; + keyExtractor: (item: T, index: number) => string; + renderItem: (info: DraggableRenderItemInfo) => ReactElement; + onDragEnd?: (data: T[]) => void; + useDragHandle?: boolean; + disabled?: boolean; + activationDistance?: number; + onDragBegin?: () => void; +}): ReactElement { + const [activeId, setActiveId] = useState(null); + const [items, setItems] = useState(() => data); + + useEffect(() => { + if (activeId) { + return; + } + setItems((current) => (current === data ? current : data)); + }, [activeId, data]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: activationDistance, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + if (disabled) { + return; + } + setActiveId(String(event.active.id)); + onDragBegin?.(); + }, + [disabled, onDragBegin] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + setActiveId(null); + + if (disabled) { + return; + } + + if (over && active.id !== over.id) { + const oldIndex = items.findIndex( + (item, i) => keyExtractor(item, i) === active.id + ); + const newIndex = items.findIndex( + (item, i) => keyExtractor(item, i) === over.id + ); + + if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { + const newItems = arrayMove(items, oldIndex, newIndex); + setItems(newItems); + onDragEnd?.(newItems); + } + } + }, + [disabled, items, keyExtractor, onDragEnd] + ); + + const ids = items.map((item, index) => keyExtractor(item, index)); + + return ( + + + {items.map((item, index) => { + const id = keyExtractor(item, index); + return ( + + ); + })} + + + ); +} diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index a15e67f2d..9ddf65442 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -16,6 +16,9 @@ interface TerminalEmulatorProps { backgroundColor?: string; foregroundColor?: string; cursorColor?: string; + swipeGesturesEnabled?: boolean; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; onInput?: (data: string) => Promise | void; onResize?: (input: { rows: number; cols: number }) => Promise | void; onTerminalKey?: (input: { @@ -45,6 +48,9 @@ export default function TerminalEmulator({ backgroundColor = "#0b0b0b", foregroundColor = "#e6e6e6", cursorColor = "#e6e6e6", + swipeGesturesEnabled = false, + onSwipeLeft, + onSwipeRight, onInput, onResize, onTerminalKey, @@ -59,6 +65,122 @@ export default function TerminalEmulator({ const runtimeRef = useRef(null); const appliedInitialOutputRef = useRef(null); + useEffect(() => { + const root = rootRef.current; + if (!root || !swipeGesturesEnabled) { + return; + } + + const SWIPE_MIN_PX = 22; + const VERTICAL_CANCEL_PX = 12; + const HORIZONTAL_DOMINANCE_RATIO = 1.2; + + let tracking = false; + let activePointerId: number | null = null; + let startX = 0; + let startY = 0; + let fired = false; + + const reset = () => { + tracking = false; + activePointerId = null; + startX = 0; + startY = 0; + fired = false; + }; + + const shouldTreatAsVertical = (dx: number, dy: number) => { + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + if (absDy < VERTICAL_CANCEL_PX) { + return false; + } + return absDy > absDx; + }; + + const shouldTreatAsHorizontal = (dx: number, dy: number) => { + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + if (absDx < SWIPE_MIN_PX) { + return false; + } + if (absDy === 0) { + return true; + } + return absDx / absDy >= HORIZONTAL_DOMINANCE_RATIO; + }; + + const onPointerDown = (event: PointerEvent) => { + if (!event.isPrimary) { + return; + } + tracking = true; + fired = false; + activePointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + }; + + const onPointerMove = (event: PointerEvent) => { + if (!tracking || fired) { + return; + } + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + + const dx = event.clientX - startX; + const dy = event.clientY - startY; + + if (shouldTreatAsVertical(dx, dy)) { + reset(); + return; + } + + if (!shouldTreatAsHorizontal(dx, dy)) { + return; + } + + fired = true; + + if (dx > 0) { + onSwipeRight?.(); + } else { + onSwipeLeft?.(); + } + + if (event.cancelable) { + event.preventDefault(); + } + }; + + const onPointerUp = (event: PointerEvent) => { + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + reset(); + }; + + const onPointerCancel = (event: PointerEvent) => { + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + reset(); + }; + + root.addEventListener("pointerdown", onPointerDown, { passive: true }); + root.addEventListener("pointermove", onPointerMove, { passive: false }); + root.addEventListener("pointerup", onPointerUp, { passive: true }); + root.addEventListener("pointercancel", onPointerCancel, { passive: true }); + + return () => { + root.removeEventListener("pointerdown", onPointerDown); + root.removeEventListener("pointermove", onPointerMove); + root.removeEventListener("pointerup", onPointerUp); + root.removeEventListener("pointercancel", onPointerCancel); + }; + }, [onSwipeLeft, onSwipeRight, swipeGesturesEnabled]); + useEffect(() => { const host = hostRef.current; const root = rootRef.current; @@ -185,6 +307,7 @@ export default function TerminalEmulator({ backgroundColor, overflow: "hidden", overscrollBehavior: "none", + touchAction: "pan-y", }} onPointerDown={() => { runtimeRef.current?.focus(); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 85442390a..a7a2bff13 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -41,6 +41,7 @@ import { summarizeTerminalText, terminalDebugLog, } from "@/terminal/runtime/terminal-debug"; +import { usePanelStore } from "@/stores/panel-store"; import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { @@ -147,6 +148,10 @@ export function TerminalPane({ const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const openAgentList = usePanelStore((state) => state.openAgentList); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const swipeGesturesEnabled = isMobile && mobileView === "agent"; const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ mode: "padding", enabled: isMobile, @@ -1121,6 +1126,19 @@ export function TerminalPane({ backgroundColor={theme.colors.background} foregroundColor={theme.colors.foreground} cursorColor={theme.colors.foreground} + swipeGesturesEnabled={swipeGesturesEnabled} + onSwipeRight={() => { + if (!swipeGesturesEnabled) { + return; + } + openAgentList(); + }} + onSwipeLeft={() => { + if (!swipeGesturesEnabled) { + return; + } + openFileExplorer(); + }} onInput={handleTerminalData} onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx new file mode 100644 index 000000000..eea75d193 --- /dev/null +++ b/packages/app/src/components/ui/context-menu.tsx @@ -0,0 +1,707 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from "react"; +import { + ActivityIndicator, + Dimensions, + Modal, + Platform, + Pressable, + ScrollView, + StatusBar, + Text, + View, + type PressableProps, + type StyleProp, + type ViewStyle, +} from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Check, CheckCircle } from "lucide-react-native"; + +// Keep parity with dropdown-menu action statuses. +export type ActionStatus = "idle" | "pending" | "success"; + +type Placement = "top" | "bottom" | "left" | "right"; +type Alignment = "start" | "center" | "end"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +type ContextMenuContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + triggerRef: React.RefObject; + anchorRect: Rect | null; + setAnchorRect: (rect: Rect | null) => void; +}; + +const ContextMenuContext = createContext(null); + +function useContextMenuContext(componentName: string): ContextMenuContextValue { + const ctx = useContext(ContextMenuContext); + if (!ctx) { + throw new Error(`${componentName} must be used within `); + } + return ctx; +} + +function useControllableOpenState({ + open, + defaultOpen, + onOpenChange, +}: { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}): [boolean, (next: boolean) => void] { + const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen)); + const isControlled = typeof open === "boolean"; + const value = isControlled ? Boolean(open) : internalOpen; + const setValue = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange] + ); + return [value, setValue]; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computePosition({ + triggerRect, + contentSize, + displayArea, + placement, + alignment, + offset, +}: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + placement: Placement; + alignment: Alignment; + offset: number; +}): { x: number; y: number; actualPlacement: Placement } { + const { width: contentWidth, height: contentHeight } = contentSize; + + // Calculate available space + const spaceTop = triggerRect.y - displayArea.y; + const spaceBottom = + displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); + + // Flip if needed + let actualPlacement = placement; + if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { + actualPlacement = "top"; + } else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { + actualPlacement = "bottom"; + } + + let x: number; + let y: number; + + // Position based on placement + if (actualPlacement === "bottom") { + y = triggerRect.y + triggerRect.height + offset; + } else if (actualPlacement === "top") { + y = triggerRect.y - contentHeight - offset; + } else if (actualPlacement === "left") { + x = triggerRect.x - contentWidth - offset; + y = triggerRect.y; + } else { + x = triggerRect.x + triggerRect.width + offset; + y = triggerRect.y; + } + + // Alignment + if (actualPlacement === "top" || actualPlacement === "bottom") { + if (alignment === "start") { + x = triggerRect.x; + } else if (alignment === "end") { + x = triggerRect.x + triggerRect.width - contentWidth; + } else { + x = triggerRect.x + (triggerRect.width - contentWidth) / 2; + } + } + + // Constrain to screen + const padding = 8; + x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!)); + y = Math.max( + displayArea.y + padding, + Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!) + ); + + return { x, y, actualPlacement }; +} + +function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null { + const nativeEvent: any = (event as any)?.nativeEvent ?? event; + const pageX = nativeEvent?.pageX; + const pageY = nativeEvent?.pageY; + if (typeof pageX === "number" && typeof pageY === "number") { + return { pageX, pageY }; + } + const clientX = nativeEvent?.clientX; + const clientY = nativeEvent?.clientY; + if (typeof clientX === "number" && typeof clientY === "number") { + return { pageX: clientX, pageY: clientY }; + } + return null; +} + +export function ContextMenu({ + open, + defaultOpen, + onOpenChange, + children, +}: PropsWithChildren<{ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}>): ReactElement { + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useControllableOpenState({ + open, + defaultOpen, + onOpenChange, + }); + const [anchorRect, setAnchorRect] = useState(null); + + useEffect(() => { + if (!isOpen) { + setAnchorRect(null); + } + }, [isOpen]); + + const value = useMemo( + () => ({ + open: isOpen, + setOpen: setIsOpen, + triggerRef, + anchorRect, + setAnchorRect, + }), + [anchorRect, isOpen, setAnchorRect, setIsOpen] + ); + + return {children}; +} + +type TriggerState = { pressed: boolean; hovered: boolean; open: boolean }; +type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); + +export function ContextMenuTrigger({ + children, + disabled, + style, + enabled = true, + enabledOnMobile = false, + enabledOnWeb = true, + longPressDelayMs, + ...props +}: PropsWithChildren< + Omit & { + style?: TriggerStyleProp; + enabled?: boolean; + enabledOnMobile?: boolean; + enabledOnWeb?: boolean; + longPressDelayMs?: number; + } +>): ReactElement { + const ctx = useContextMenuContext("ContextMenuTrigger"); + + const shouldEnableOnThisPlatform = + enabled && + (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile); + + const openAtEvent = useCallback( + (event: unknown) => { + if (!shouldEnableOnThisPlatform || disabled) { + return; + } + const point = coerceEventPoint(event); + if (!point) { + return; + } + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + ctx.setAnchorRect({ + x: point.pageX, + y: point.pageY + statusBarHeight, + width: 0, + height: 0, + }); + ctx.setOpen(true); + }, + [ctx, disabled, shouldEnableOnThisPlatform] + ); + + return ( + { + if (Platform.OS === "web") { + props.onLongPress?.(event); + return; + } + openAtEvent(event); + props.onLongPress?.(event); + }} + // @ts-ignore - onContextMenu is web-only and not in RN types. + onContextMenu={(event: unknown) => { + if (Platform.OS !== "web") { + return; + } + const e: any = event; + e?.preventDefault?.(); + e?.stopPropagation?.(); + openAtEvent(event); + }} + style={({ pressed, hovered = false }) => { + if (typeof style === "function") { + return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); + } + return style; + }} + > + {children} + + ); +} + +export function ContextMenuContent({ + children, + side = "bottom", + align = "start", + offset = 4, + width, + minWidth = 180, + maxWidth, + fullWidth = false, + horizontalPadding = 16, + testID, +}: PropsWithChildren<{ + side?: Placement; + align?: Alignment; + offset?: number; + width?: number; + minWidth?: number; + maxWidth?: number; + fullWidth?: boolean; + horizontalPadding?: number; + testID?: string; +}>): ReactElement | null { + const { open, setOpen, triggerRef, anchorRect } = useContextMenuContext("ContextMenuContent"); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + const handleClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + // Measure trigger when opening (fallback) and capture point anchors. + useEffect(() => { + if (!open) { + setTriggerRect(null); + setContentSize(null); + setPosition(null); + return; + } + + if (anchorRect) { + setTriggerRect(anchorRect); + return; + } + + if (!triggerRef.current) { + setTriggerRect(null); + return; + } + + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + let cancelled = false; + + measureElement(triggerRef.current).then((rect) => { + if (cancelled) return; + setTriggerRect({ + ...rect, + y: rect.y + statusBarHeight, + }); + }); + + return () => { + cancelled = true; + }; + }, [anchorRect, open, triggerRef]); + + // Calculate position when we have both measurements + useEffect(() => { + if (!triggerRect || !contentSize) return; + + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const displayArea = { + x: 0, + y: 0, + width: screenWidth, + height: screenHeight, + }; + + const result = computePosition({ + triggerRect, + contentSize, + displayArea, + placement: side, + alignment: align, + offset, + }); + + const x = fullWidth ? horizontalPadding : result.x; + setPosition({ x, y: result.y }); + }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]); + + const handleContentLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width: w, height: h } = event.nativeEvent.layout; + setContentSize({ width: w, height: h }); + }, + [] + ); + + if (!open) return null; + + const { width: screenWidth } = Dimensions.get("window"); + const resolvedWidthStyle: ViewStyle = fullWidth + ? { width: screenWidth - horizontalPadding * 2 } + : { + ...(typeof width === "number" ? { width } : null), + ...(typeof minWidth === "number" ? { minWidth } : null), + ...(typeof maxWidth === "number" ? { maxWidth } : null), + }; + + return ( + + + + + + {children} + + + + + ); +} + +export function ContextMenuLabel({ + children, + style, + testID, +}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { + return ( + + {children} + + ); +} + +export function ContextMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement { + return ; +} + +export function ContextMenuHint({ + children, + testID, +}: PropsWithChildren<{ testID?: string }>): ReactElement { + return ( + + {children} + + ); +} + +export function ContextMenuItem({ + children, + description, + onSelect, + disabled, + destructive, + selected, + showSelectedCheck = false, + selectedVariant = "default", + leading, + trailing, + loading, + status, + pendingLabel, + successLabel, + closeOnSelect = true, + testID, +}: PropsWithChildren<{ + description?: string; + onSelect?: () => void; + disabled?: boolean; + destructive?: boolean; + selected?: boolean; + showSelectedCheck?: boolean; + selectedVariant?: "default" | "accent"; + leading?: ReactElement | null; + trailing?: ReactElement | null; + /** @deprecated Use `status` instead */ + loading?: boolean; + status?: ActionStatus; + pendingLabel?: string; + successLabel?: string; + closeOnSelect?: boolean; + testID?: string; +}>): ReactElement { + const { theme } = useUnistyles(); + const { setOpen } = useContextMenuContext("ContextMenuItem"); + + const isPending = status === "pending" || loading; + const isSuccess = status === "success"; + const isDisabled = disabled || isPending || isSuccess; + + let leadingContent: ReactElement | null = null; + if (isPending) { + leadingContent = ; + } else if (isSuccess) { + leadingContent = ; + } else if (leading) { + leadingContent = leading; + } + + let label = children; + if (isPending && pendingLabel) { + label = pendingLabel; + } else if (isSuccess && successLabel) { + label = successLabel; + } + + const trailingContent = + trailing ?? (!showSelectedCheck && selected ? : null); + + return ( + { + if (isDisabled) return; + if (closeOnSelect) { + setOpen(false); + } + onSelect?.(); + }} + style={({ pressed, hovered }) => [ + styles.item, + selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null, + selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null, + isDisabled ? styles.itemDisabled : null, + hovered && !pressed && !isDisabled ? styles.itemHovered : null, + pressed && !isDisabled ? styles.itemPressed : null, + ]} + > + {showSelectedCheck ? ( + + {selected ? : null} + + ) : null} + {leadingContent ? {leadingContent} : null} + + + {label} + + {description && !isPending && !isSuccess ? ( + + {description} + + ) : null} + + {trailingContent ? {trailingContent} : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + flex: 1, + }, + backdrop: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + content: { + backgroundColor: theme.colors.surface0, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + overflow: "hidden", + }, + labelContainer: { + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + labelText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + hintContainer: { + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + hintText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + item: { + flexDirection: "row", + alignItems: "center", + minHeight: 36, + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderWidth: theme.borderWidth[1], + borderColor: "transparent", + }, + itemHovered: { + backgroundColor: theme.colors.surface1, + }, + itemPressed: { + backgroundColor: theme.colors.surface1, + }, + itemDisabled: { + opacity: 0.5, + }, + itemSelected: { + backgroundColor: theme.colors.surface1, + }, + itemSelectedInteractive: { + backgroundColor: theme.colors.surface1, + }, + itemSelectedAccent: { + backgroundColor: theme.colors.accent, + }, + checkSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + leadingSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + trailingSlot: { + marginLeft: "auto", + alignItems: "center", + justifyContent: "center", + }, + itemContent: { + flexShrink: 1, + }, + itemText: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + }, + itemTextDestructive: { + color: theme.colors.destructive, + }, + itemTextSuccess: { + color: theme.colors.palette.green[500], + }, + itemTextSelectedAccent: { + color: theme.colors.accentForeground, + }, + itemDescription: { + marginTop: 2, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + itemDescriptionSelectedAccent: { + color: theme.colors.accentForeground, + opacity: 0.85, + }, +})); diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts index d9fa55fdd..79723979e 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -393,7 +393,10 @@ export function useSidebarAgentsList(options?: { serverId, cwd: checkoutCwd, branchName: normalizedBranch, - createdAt: isMainCheckout ? sourceAgent.createdAt : null, + // Only Paseo-managed worktrees are ephemeral enough to justify showing a timestamp. + // If the daemon doesn't provide worktree.createdAt (older daemons), fall back to an + // agent-derived timestamp so the row still has a date. + createdAt: placement.checkout.isPaseoOwnedWorktree ? sourceAgent.createdAt : null, isMainCheckout, isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree, }) diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e3988f665..0b16faf97 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -10,6 +10,7 @@ import { } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; +import * as Clipboard from "expo-clipboard"; import { Bot, ChevronDown, @@ -35,10 +36,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { TerminalPane } from "@/components/terminal-pane"; +import { SortableInlineList } from "@/components/sortable-inline-list"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; +import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -52,6 +62,7 @@ import { buildHostWorkspaceRoute, buildHostWorkspaceAgentRoute, buildHostWorkspaceTerminalRoute, + decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; import { buildNewAgentRoute } from "@/utils/new-agent-routing"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; @@ -66,11 +77,13 @@ 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"; +import { buildProviderCommand } from "@/utils/provider-command-templates"; const TERMINALS_QUERY_STALE_TIME = 5_000; const DROPDOWN_WIDTH = 220; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__"; +const EMPTY_TAB_ORDER: string[] = []; type TabAvailability = "available" | "invalid" | "unknown"; @@ -99,6 +112,41 @@ type WorkspaceTabDescriptor = subtitle: string; }; +function applyWorkspaceTabOrder(input: { + tabs: WorkspaceTabDescriptor[]; + keys: string[]; +}): WorkspaceTabDescriptor[] { + if (input.keys.length === 0) { + return input.tabs; + } + + const byKey = new Map(); + for (const tab of input.tabs) { + byKey.set(tab.key, tab); + } + + const used = new Set(); + const next: WorkspaceTabDescriptor[] = []; + + for (const key of input.keys) { + const tab = byKey.get(key); + if (!tab) { + continue; + } + used.add(key); + next.push(tab); + } + + for (const tab of input.tabs) { + if (used.has(tab.key)) { + continue; + } + next.push(tab); + } + + return next; +} + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -266,12 +314,13 @@ function WorkspaceScreenContent({ routeTab, }: WorkspaceScreenProps) { const { theme } = useUnistyles(); + const toast = useToast(); const router = useRouter(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; - const normalizedWorkspaceId = trimNonEmpty(decodeSegment(workspaceId)) ?? ""; + const normalizedWorkspaceId = decodeWorkspaceIdFromPathSegment(workspaceId) ?? ""; const queryClient = useQueryClient(); const { client, isConnected } = useHostRuntimeSession(normalizedServerId); @@ -517,7 +566,7 @@ function WorkspaceScreenContent({ return map; }, [workspaceAgents]); - const tabs = useMemo(() => { + const baseTabs = useMemo(() => { const next: WorkspaceTabDescriptor[] = []; for (const agent of workspaceAgents) { @@ -565,12 +614,24 @@ function WorkspaceScreenContent({ }), [normalizedServerId, normalizedWorkspaceId] ); + + const tabOrder = useWorkspaceTabsStore((state) => + persistenceKey + ? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER + : EMPTY_TAB_ORDER + ); const lastFocusedTabByWorkspace = useWorkspaceTabsStore( (state) => state.lastFocusedTabByWorkspace ); const setLastFocusedTab = useWorkspaceTabsStore( (state) => state.setLastFocusedTab ); + const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder); + + const tabs = useMemo( + () => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }), + [baseTabs, tabOrder] + ); const storedTab = useMemo(() => { if (!persistenceKey) { @@ -590,6 +651,17 @@ function WorkspaceScreenContent({ return { kind: "terminal", terminalId: first.terminalId }; }, [tabs]); + const handleReorderTabs = useCallback( + (nextTabs: WorkspaceTabDescriptor[]) => { + setTabOrder({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + keys: nextTabs.map((tab) => tab.key), + }); + }, + [normalizedServerId, normalizedWorkspaceId, setTabOrder] + ); + const requestedTabAvailability = useMemo(() => { if (!requestedTab) { return null; @@ -964,6 +1036,144 @@ function WorkspaceScreenContent({ ] ); + const handleCopyAgentId = useCallback( + async (agentId: string) => { + if (!agentId) return; + try { + await Clipboard.setStringAsync(agentId); + toast.copied("Agent ID"); + } catch { + toast.error("Copy failed"); + } + }, + [toast] + ); + + const handleCopyResumeCommand = useCallback( + async (agentId: string) => { + if (!agentId) return; + const agent = agentsById.get(agentId) ?? null; + const providerSessionId = + agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; + if (!agent || !providerSessionId) { + toast.error("Resume ID not available"); + return; + } + + const command = + buildProviderCommand({ + provider: agent.provider, + id: "resume", + sessionId: providerSessionId, + }) ?? null; + if (!command) { + toast.error("Resume command not available"); + return; + } + try { + await Clipboard.setStringAsync(command); + toast.copied("resume command"); + } catch { + toast.error("Copy failed"); + } + }, + [agentsById, toast] + ); + + const handleCloseTabsToRight = useCallback( + async (tabKey: string) => { + const startIndex = tabs.findIndex((tab) => tab.key === tabKey); + if (startIndex < 0) { + return; + } + const toClose = tabs.slice(startIndex + 1); + if (toClose.length === 0) { + return; + } + + const agentIds: string[] = []; + const terminalIdsToClose: string[] = []; + for (const tab of toClose) { + if (tab.kind === "agent") { + agentIds.push(tab.agentId); + } else { + terminalIdsToClose.push(tab.terminalId); + } + } + + const confirmed = await confirmDialog({ + title: "Close tabs to the right?", + message: + agentIds.length > 0 && terminalIdsToClose.length > 0 + ? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.` + : terminalIdsToClose.length > 0 + ? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.` + : `This will archive ${agentIds.length} agent(s).`, + confirmLabel: "Close", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) { + return; + } + + for (const terminalId of terminalIdsToClose) { + try { + await killTerminalMutation.mutateAsync(terminalId); + queryClient.setQueryData(terminalsQueryKey, (current) => { + if (!current) { + return current; + } + return { + ...current, + terminals: current.terminals.filter((terminal) => terminal.id !== terminalId), + }; + }); + } catch (error) { + console.warn("[WorkspaceScreen] Failed to close terminal tab to the right", { terminalId, error }); + } + } + + for (const agentId of agentIds) { + if (!normalizedServerId) { + continue; + } + try { + await archiveAgent({ serverId: normalizedServerId, agentId }); + } catch (error) { + console.warn("[WorkspaceScreen] Failed to archive agent tab to the right", { agentId, error }); + } + } + + const resolvedTabKey = resolvedTab?.kind === "agent" + ? `agent:${resolvedTab.agentId}` + : resolvedTab?.kind === "terminal" + ? `terminal:${resolvedTab.terminalId}` + : null; + const closedKeys = new Set(toClose.map((tab) => tab.key)); + if (resolvedTabKey && closedKeys.has(resolvedTabKey)) { + const target = tabByKey.get(tabKey); + if (target) { + navigateToTab(target); + } + } + + setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current)); + setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); + }, + [ + archiveAgent, + killTerminalMutation, + navigateToTab, + normalizedServerId, + queryClient, + resolvedTab, + tabByKey, + tabs, + terminalsQueryKey, + ] + ); + const handleOpenAgentChatView = useCallback(() => { if (!activeAgent) { return; @@ -1253,155 +1463,229 @@ function WorkspaceScreenContent({ contentContainerStyle={styles.tabsContent} showsHorizontalScrollIndicator={false} > - {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, - }); - 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} - - ) : ( - - ); + tab.key} + useDragHandle + disabled={tabs.length < 2} + onDragEnd={handleReorderTabs} + renderItem={({ item: tab, dragHandleProps }) => { + const isActive = tab.key === activeTabKey; + const tabAgent = + tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; + const isCloseHovered = hoveredCloseTabKey === tab.key; + const isClosingAgent = + tab.kind === "agent" && + isArchivingAgent({ + serverId: normalizedServerId, + agentId: tab.agentId, + }); + 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={() => { - 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, - }); - }} - > - {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 ? ( - - ) : ( - - )} - - - ); - })} + const contextMenuTestId = `workspace-tab-context-${tab.key}`; + + return ( + + [ + styles.tab, + isActive && styles.tabActive, + (hovered || pressed || isCloseHovered) && 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, + }); + }} + > + { + dragHandleProps?.setActivatorNodeRef?.(node); + }} + style={styles.tabHandle} + > + {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 ? ( + + ) : ( + + )} + + + + + {tab.kind === "agent" ? ( + <> + { + void handleCopyResumeCommand(tab.agentId); + }} + > + Copy resume command + + { + void handleCopyAgentId(tab.agentId); + }} + > + Copy agent id + + + ) : null} + + + + t.key === tab.key) === tabs.length - 1 + } + onSelect={() => { + void handleCloseTabsToRight(tab.key); + }} + > + Close to the right + + { + if (tab.kind === "agent") { + void handleCloseAgentTab(tab.agentId); + return; + } + void handleCloseTerminalTab(tab.terminalId); + }} + > + Close + + + + ); + }} + /> @@ -1644,6 +1928,13 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], maxWidth: 260, }, + tabHandle: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flex: 1, + minWidth: 0, + }, tabIcon: { flexShrink: 0, }, diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index f9c9e4f88..6c443d4db 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -6,6 +6,22 @@ export type WorkspaceTabTarget = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string }; +function normalizeKeys(keys: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const rawKey of keys) { + const key = rawKey.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + normalized.push(key); + } + + return normalized; +} + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -51,6 +67,7 @@ export function buildWorkspaceTabPersistenceKey(input: { type WorkspaceTabsState = { lastFocusedTabByWorkspace: Record; + tabOrderByWorkspace: Record; setLastFocusedTab: (input: { serverId: string; workspaceId: string; @@ -60,12 +77,18 @@ type WorkspaceTabsState = { serverId: string; workspaceId: string; }) => WorkspaceTabTarget | null; + setTabOrder: (input: { + serverId: string; + workspaceId: string; + keys: string[]; + }) => void; }; export const useWorkspaceTabsStore = create()( persist( (set, get) => ({ lastFocusedTabByWorkspace: {}, + tabOrderByWorkspace: {}, setLastFocusedTab: ({ serverId, workspaceId, tab }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTab = normalizeWorkspaceTab(tab); @@ -103,15 +126,45 @@ export const useWorkspaceTabsStore = create()( const value = get().lastFocusedTabByWorkspace[key]; return normalizeWorkspaceTab(value); }, + setTabOrder: ({ serverId, workspaceId, keys }) => { + const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!key) { + return; + } + const normalized = normalizeKeys(keys); + set((state) => { + const current = state.tabOrderByWorkspace[key] ?? []; + if (current.length === normalized.length) { + let isSame = true; + for (let index = 0; index < current.length; index += 1) { + if (current[index] !== normalized[index]) { + isSame = false; + break; + } + } + if (isSame) { + return state; + } + } + + return { + tabOrderByWorkspace: { + ...state.tabOrderByWorkspace, + [key]: normalized, + }, + }; + }); + }, }), { name: "workspace-tabs-state", - version: 1, + version: 2, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState) => { const state = persistedState as | { lastFocusedTabByWorkspace?: Record; + tabOrderByWorkspace?: Record; } | undefined; @@ -126,9 +179,23 @@ export const useWorkspaceTabsStore = create()( } } + const rawOrder = state?.tabOrderByWorkspace ?? {}; + const nextOrder: Record = {}; + for (const key in rawOrder) { + const list = rawOrder[key]; + if (!Array.isArray(list)) { + continue; + } + const normalized = normalizeKeys(list.map((value) => String(value))); + if (normalized.length > 0) { + nextOrder[key] = normalized; + } + } + return { ...state, lastFocusedTabByWorkspace: next, + tabOrderByWorkspace: nextOrder, }; }, } diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index f0f4cb86e..205e8bdfc 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + buildHostWorkspaceRoute, + decodeWorkspaceIdFromPathSegment, + encodeWorkspaceIdForPathSegment, parseHostAgentDraftRouteFromPathname, parseHostAgentRouteFromPathname, parseHostDraftRouteFromPathname, @@ -46,9 +49,14 @@ describe("parseHostAgentRouteFromPathname", () => { }); describe("workspace route parsing", () => { + it("encodes workspace IDs as base64url (no padding)", () => { + expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv"); + expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo"); + }); + it("parses workspace route", () => { expect( - parseHostWorkspaceRouteFromPathname("/h/local/workspace/%2Ftmp%2Frepo") + parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv") ).toEqual({ serverId: "local", workspaceId: "/tmp/repo", @@ -58,7 +66,7 @@ describe("workspace route parsing", () => { it("parses workspace agent route", () => { expect( parseHostWorkspaceAgentRouteFromPathname( - "/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1" + "/h/local/workspace/L3RtcC9yZXBv/agent/agent-1" ) ).toEqual({ serverId: "local", @@ -70,7 +78,7 @@ describe("workspace route parsing", () => { it("parses workspace terminal route", () => { expect( parseHostWorkspaceTerminalRouteFromPathname( - "/h/local/workspace/%2Ftmp%2Frepo/terminal/term-1" + "/h/local/workspace/L3RtcC9yZXBv/terminal/term-1" ) ).toEqual({ serverId: "local", @@ -78,4 +86,22 @@ describe("workspace route parsing", () => { terminalId: "term-1", }); }); + + it("still parses legacy percent-encoded workspace routes", () => { + expect( + parseHostWorkspaceAgentRouteFromPathname( + "/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1" + ) + ).toEqual({ + serverId: "local", + workspaceId: "/tmp/repo", + agentId: "agent-1", + }); + }); + + it("builds base64url workspace routes", () => { + expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe( + "/h/local/workspace/L3RtcC9yZXBv" + ); + }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 299de8a81..48224363f 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -1,3 +1,5 @@ +import { Buffer } from "buffer"; + type NullableString = string | null | undefined; function trimNonEmpty(value: NullableString): string | null { @@ -20,6 +22,78 @@ function decodeSegment(value: string): string { } } +function toBase64UrlNoPad(input: string): string { + return Buffer.from(input, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function tryDecodeBase64UrlNoPadUtf8(input: string): string | null { + const normalized = input.trim(); + if (normalized.length === 0) { + return null; + } + if (!/^[A-Za-z0-9_-]+$/.test(normalized)) { + return null; + } + + const base64 = normalized.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + + let decoded: string; + try { + decoded = Buffer.from(padded, "base64").toString("utf8"); + } catch { + return null; + } + + // Validate via round-trip to avoid false positives ("workspace-1" etc). + if (toBase64UrlNoPad(decoded) !== normalized) { + return null; + } + + return decoded; +} + +function normalizeWorkspaceId(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + +export function encodeWorkspaceIdForPathSegment(workspaceId: string): string { + const normalized = trimNonEmpty(workspaceId); + if (!normalized) { + return ""; + } + return toBase64UrlNoPad(normalizeWorkspaceId(normalized)); +} + +export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null { + const normalizedSegment = trimNonEmpty(workspaceIdSegment); + if (!normalizedSegment) { + return null; + } + + // Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies. + const decoded = trimNonEmpty(decodeSegment(normalizedSegment)); + if (!decoded) { + return null; + } + + // Legacy: if it already looks like a path after decoding, keep it. + if (decoded.includes("/") || decoded.includes("\\")) { + return normalizeWorkspaceId(decoded); + } + + const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded); + if (base64Decoded) { + return normalizeWorkspaceId(base64Decoded); + } + + return normalizeWorkspaceId(decoded); +} + export function parseServerIdFromPathname(pathname: string): string | null { const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/); if (!match) { @@ -105,17 +179,42 @@ export function buildHostDraftRoute(serverId: string): string { export function parseHostWorkspaceRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string } | null { - const match = pathname.match(/^\/h\/([^/]+)\/workspace\/([^/]+)(?:\/|$)/); - if (!match) { + const prefix = "/h/"; + if (!pathname.startsWith(prefix)) { return null; } - const [, encodedServerId, encodedWorkspaceId] = match; - if (!encodedServerId || !encodedWorkspaceId) { + + const serverIdStart = prefix.length; + const serverIdEnd = pathname.indexOf("/", serverIdStart); + if (serverIdEnd < 0) { return null; } - const serverId = trimNonEmpty(decodeSegment(encodedServerId)); - const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId)); - if (!serverId || !workspaceId) { + const rawServerId = pathname.slice(serverIdStart, serverIdEnd); + const serverId = trimNonEmpty(decodeSegment(rawServerId)); + if (!serverId) { + return null; + } + + const workspacePrefix = "/workspace/"; + if (!pathname.startsWith(workspacePrefix, serverIdEnd)) { + return null; + } + + const workspaceIdStart = serverIdEnd + workspacePrefix.length; + let workspaceIdEnd = pathname.length; + + const agentIdx = pathname.lastIndexOf("/agent/"); + if (agentIdx >= 0 && agentIdx > workspaceIdStart) { + workspaceIdEnd = Math.min(workspaceIdEnd, agentIdx); + } + const terminalIdx = pathname.lastIndexOf("/terminal/"); + if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) { + workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx); + } + + const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, ""); + const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); + if (!workspaceId) { return null; } return { serverId, workspaceId }; @@ -124,44 +223,100 @@ export function parseHostWorkspaceRouteFromPathname( export function parseHostWorkspaceAgentRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string; agentId: string } | null { - const match = pathname.match( - /^\/h\/([^/]+)\/workspace\/([^/]+)\/agent\/([^/]+)(?:\/|$)/ - ); - if (!match) { + const prefix = "/h/"; + if (!pathname.startsWith(prefix)) { return null; } - const [, encodedServerId, encodedWorkspaceId, encodedAgentId] = match; - if (!encodedServerId || !encodedWorkspaceId || !encodedAgentId) { + + const serverIdStart = prefix.length; + const serverIdEnd = pathname.indexOf("/", serverIdStart); + if (serverIdEnd < 0) { return null; } - const serverId = trimNonEmpty(decodeSegment(encodedServerId)); - const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId)); - const agentId = trimNonEmpty(decodeSegment(encodedAgentId)); - if (!serverId || !workspaceId || !agentId) { + const rawServerId = pathname.slice(serverIdStart, serverIdEnd); + const serverId = trimNonEmpty(decodeSegment(rawServerId)); + if (!serverId) { return null; } + + const workspacePrefix = "/workspace/"; + if (!pathname.startsWith(workspacePrefix, serverIdEnd)) { + return null; + } + + const workspaceIdStart = serverIdEnd + workspacePrefix.length; + const agentMarker = "/agent/"; + const agentIdx = pathname.lastIndexOf(agentMarker); + if (agentIdx < 0 || agentIdx <= workspaceIdStart) { + return null; + } + + const rawWorkspaceId = pathname.slice(workspaceIdStart, agentIdx).replace(/\/+$/, ""); + const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); + if (!workspaceId) { + return null; + } + + const agentIdStart = agentIdx + agentMarker.length; + const agentIdEnd = pathname.indexOf("/", agentIdStart); + const rawAgentId = + agentIdEnd < 0 ? pathname.slice(agentIdStart) : pathname.slice(agentIdStart, agentIdEnd); + const agentId = trimNonEmpty(decodeSegment(rawAgentId)); + if (!agentId) { + return null; + } + return { serverId, workspaceId, agentId }; } export function parseHostWorkspaceTerminalRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string; terminalId: string } | null { - const match = pathname.match( - /^\/h\/([^/]+)\/workspace\/([^/]+)\/terminal\/([^/]+)(?:\/|$)/ - ); - if (!match) { + const prefix = "/h/"; + if (!pathname.startsWith(prefix)) { return null; } - const [, encodedServerId, encodedWorkspaceId, encodedTerminalId] = match; - if (!encodedServerId || !encodedWorkspaceId || !encodedTerminalId) { + + const serverIdStart = prefix.length; + const serverIdEnd = pathname.indexOf("/", serverIdStart); + if (serverIdEnd < 0) { return null; } - const serverId = trimNonEmpty(decodeSegment(encodedServerId)); - const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId)); - const terminalId = trimNonEmpty(decodeSegment(encodedTerminalId)); - if (!serverId || !workspaceId || !terminalId) { + const rawServerId = pathname.slice(serverIdStart, serverIdEnd); + const serverId = trimNonEmpty(decodeSegment(rawServerId)); + if (!serverId) { return null; } + + const workspacePrefix = "/workspace/"; + if (!pathname.startsWith(workspacePrefix, serverIdEnd)) { + return null; + } + + const workspaceIdStart = serverIdEnd + workspacePrefix.length; + const terminalMarker = "/terminal/"; + const terminalIdx = pathname.lastIndexOf(terminalMarker); + if (terminalIdx < 0 || terminalIdx <= workspaceIdStart) { + return null; + } + + const rawWorkspaceId = pathname.slice(workspaceIdStart, terminalIdx).replace(/\/+$/, ""); + const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); + if (!workspaceId) { + return null; + } + + const terminalIdStart = terminalIdx + terminalMarker.length; + const terminalIdEnd = pathname.indexOf("/", terminalIdStart); + const rawTerminalId = + terminalIdEnd < 0 + ? pathname.slice(terminalIdStart) + : pathname.slice(terminalIdStart, terminalIdEnd); + const terminalId = trimNonEmpty(decodeSegment(rawTerminalId)); + if (!terminalId) { + return null; + } + return { serverId, workspaceId, terminalId }; } @@ -174,9 +329,11 @@ export function buildHostWorkspaceRoute( if (!normalizedServerId || !normalizedWorkspaceId) { return "/"; } - return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment( - normalizedWorkspaceId - )}`; + const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId); + if (!encodedWorkspaceId) { + return "/"; + } + return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`; } export function buildHostWorkspaceAgentRoute( diff --git a/packages/app/src/utils/provider-command-templates.ts b/packages/app/src/utils/provider-command-templates.ts new file mode 100644 index 000000000..93abd6240 --- /dev/null +++ b/packages/app/src/utils/provider-command-templates.ts @@ -0,0 +1,40 @@ +export type ProviderCommandId = "resume"; + +/** + * Declarative command templates for provider-native CLIs. + * + * Note: these are NOT Paseo agent IDs. They take provider-native session IDs. + * Example placeholders: + * - {sessionId} + */ +export const PROVIDER_COMMAND_TEMPLATES: Record< + string, + Partial> +> = { + codex: { + resume: "codex resume {sessionId}", + }, + claude: { + resume: "claude --resume {sessionId}", + }, +}; + +function renderTemplate( + template: string, + vars: Record +): string { + return template.replace(/\{(\w+)\}/g, (_match, key: string) => vars[key] ?? ""); +} + +export function buildProviderCommand(input: { + provider: string; + id: ProviderCommandId; + sessionId: string; +}): string | null { + const template = PROVIDER_COMMAND_TEMPLATES[input.provider]?.[input.id] ?? null; + if (!template) { + return null; + } + return renderTemplate(template, { sessionId: input.sessionId }); +} + 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 416c18618..c89a6cae9 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -56,16 +56,14 @@ const shouldRun = !process.env.CI; }, 60000); test( - "lists terminals for a directory (auto-creates first)", + "lists terminals for a directory (empty when none exist)", async () => { const cwd = tmpCwd(); const result = await ctx.client.listTerminals(cwd); expect(result.cwd).toBe(cwd); - expect(result.terminals).toHaveLength(1); - expect(result.terminals[0].name).toBe("Terminal 1"); - expect(result.terminals[0].id).toBeTruthy(); + expect(result.terminals).toHaveLength(0); rmSync(cwd, { recursive: true, force: true }); }, @@ -73,14 +71,11 @@ const shouldRun = !process.env.CI; ); test( - "creates additional terminal with custom name", + "creates terminal with custom name", async () => { const cwd = tmpCwd(); - // First call auto-creates Terminal 1 - await ctx.client.listTerminals(cwd); - - // Create a second terminal with custom name + // Create a terminal with custom name const result = await ctx.client.createTerminal(cwd, "Dev Server"); expect(result.error).toBeNull(); @@ -88,9 +83,9 @@ const shouldRun = !process.env.CI; expect(result.terminal!.name).toBe("Dev Server"); expect(result.terminal!.cwd).toBe(cwd); - // Verify list now shows two terminals + // Verify list now shows one terminal const list = await ctx.client.listTerminals(cwd); - expect(list.terminals).toHaveLength(2); + expect(list.terminals).toHaveLength(1); rmSync(cwd, { recursive: true, force: true }); }, @@ -101,7 +96,6 @@ const shouldRun = !process.env.CI; "emits terminals_changed for subscribed cwd when terminals are created", async () => { const cwd = tmpCwd(); - await ctx.client.listTerminals(cwd); const snapshots: Array<{ cwd: string; names: string[] }> = []; const unsubscribe = ctx.client.on("terminals_changed", (message) => { @@ -138,9 +132,8 @@ const shouldRun = !process.env.CI; async () => { const cwd = tmpCwd(); - // Get terminal (auto-creates) - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; // Subscribe to terminal const subscribeResult = await ctx.client.subscribeTerminal(terminalId); @@ -166,9 +159,8 @@ const shouldRun = !process.env.CI; async () => { const cwd = tmpCwd(); - // Get terminal - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; // Subscribe to terminal await ctx.client.subscribeTerminal(terminalId); @@ -251,9 +243,8 @@ const shouldRun = !process.env.CI; async () => { const cwd = tmpCwd(); - // Get terminal - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; // Subscribe to terminal await ctx.client.subscribeTerminal(terminalId); @@ -318,8 +309,8 @@ const shouldRun = !process.env.CI; "streams terminal output over binary mux and supports modifier keys", async () => { const cwd = tmpCwd(); - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 }); expect(attach.error).toBeNull(); @@ -357,8 +348,8 @@ const shouldRun = !process.env.CI; "emits terminal_stream_exit and removes terminal when shell exits", async () => { const cwd = tmpCwd(); - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 }); expect(attach.error).toBeNull(); @@ -395,8 +386,8 @@ const shouldRun = !process.env.CI; "replays detached terminal output from resume offset (scrollback continuity)", async () => { const cwd = tmpCwd(); - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 }); expect(attach.error).toBeNull(); @@ -448,8 +439,8 @@ const shouldRun = !process.env.CI; "applies stream backpressure window until client ack advances", async () => { const cwd = tmpCwd(); - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`); await new Promise((resolve, reject) => { @@ -646,8 +637,8 @@ const shouldRun = !process.env.CI; "measures local terminal round-trip latency via daemon client stream", async () => { const cwd = tmpCwd(); - const list = await ctx.client.listTerminals(cwd); - const terminalId = list.terminals[0].id; + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; const attach = await ctx.client.attachTerminalStream(terminalId); expect(attach.error).toBeNull(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ab07ddfe1..813a9a79e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -6428,19 +6428,12 @@ export class Session { return } - const hadDirectoryBeforeSubscribe = this.terminalManager.listDirectories().includes(cwd) - try { const terminals = await this.terminalManager.getTerminals(cwd) for (const terminal of terminals) { this.ensureTerminalExitSubscription(terminal) } - // New directories auto-create Terminal 1, which already emits through - // terminal-manager change listeners. - if (!hadDirectoryBeforeSubscribe) { - return - } if (!this.subscribedTerminalDirectories.has(cwd)) { return } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 12335394c..79d67f9aa 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -50,22 +50,22 @@ describe("TerminalManager", () => { }); describe("getTerminals", () => { - it("auto-creates first terminal for new cwd", async () => { + it("returns empty list for new cwd", async () => { manager = createTerminalManager(); const terminals = await manager.getTerminals("/tmp"); - expect(terminals.length).toBe(1); - expect(terminals[0].name).toBe("Terminal 1"); - expect(terminals[0].cwd).toBe("/tmp"); + expect(terminals).toHaveLength(0); }); it("returns existing terminals on subsequent calls", async () => { manager = createTerminalManager(); + const created = await manager.createTerminal({ cwd: "/tmp" }); const first = await manager.getTerminals("/tmp"); const second = await manager.getTerminals("/tmp"); - expect(first).toBe(second); expect(first.length).toBe(1); + expect(first[0].id).toBe(created.id); + expect(second.length).toBe(1); }); it("throws for relative paths", async () => { @@ -75,8 +75,8 @@ describe("TerminalManager", () => { it("creates separate terminals for different cwds", async () => { manager = createTerminalManager(); - const tmpTerminals = await manager.getTerminals("/tmp"); - const homeTerminals = await manager.getTerminals("/home"); + const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })]; + const homeTerminals = [await manager.createTerminal({ cwd: "/home" })]; expect(tmpTerminals.length).toBe(1); expect(homeTerminals.length).toBe(1); @@ -87,7 +87,7 @@ describe("TerminalManager", () => { describe("createTerminal", () => { it("creates additional terminal with auto-incrementing name", async () => { manager = createTerminalManager(); - await manager.getTerminals("/tmp"); + await manager.createTerminal({ cwd: "/tmp" }); const second = await manager.createTerminal({ cwd: "/tmp" }); expect(second.name).toBe("Terminal 2"); @@ -175,10 +175,10 @@ describe("TerminalManager", () => { describe("getTerminal", () => { it("returns terminal by id", async () => { manager = createTerminalManager(); - const terminals = await manager.getTerminals("/tmp"); - const found = manager.getTerminal(terminals[0].id); + const session = await manager.createTerminal({ cwd: "/tmp" }); + const found = manager.getTerminal(session.id); - expect(found).toBe(terminals[0]); + expect(found).toBe(session); }); it("returns undefined for unknown id", () => { @@ -192,28 +192,27 @@ describe("TerminalManager", () => { describe("killTerminal", () => { it("removes terminal from manager", async () => { manager = createTerminalManager(); - const terminals = await manager.getTerminals("/tmp"); - const id = terminals[0].id; + const session = await manager.createTerminal({ cwd: "/tmp" }); + const id = session.id; manager.killTerminal(id); expect(manager.getTerminal(id)).toBeUndefined(); }); - it("keeps cwd entry when last terminal is killed (but does not auto-recreate)", async () => { + it("removes cwd entry when last terminal is killed", async () => { manager = createTerminalManager(); - const terminals = await manager.getTerminals("/tmp"); + const created = await manager.createTerminal({ cwd: "/tmp" }); + manager.killTerminal(created.id); - manager.killTerminal(terminals[0].id); - - expect(manager.listDirectories()).toContain("/tmp"); const remaining = await manager.getTerminals("/tmp"); expect(remaining).toHaveLength(0); + expect(manager.listDirectories()).not.toContain("/tmp"); }); it("keeps cwd entry when other terminals remain", async () => { manager = createTerminalManager(); - await manager.getTerminals("/tmp"); + await manager.createTerminal({ cwd: "/tmp" }); const second = await manager.createTerminal({ cwd: "/tmp" }); const terminals = await manager.getTerminals("/tmp"); @@ -232,9 +231,9 @@ describe("TerminalManager", () => { it("auto-removes terminal when shell exits", async () => { manager = createTerminalManager(); - const terminals = await manager.getTerminals("/tmp"); - const exitedId = terminals[0].id; - terminals[0].kill(); + const session = await manager.createTerminal({ cwd: "/tmp" }); + const exitedId = session.id; + session.kill(); await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000); @@ -251,10 +250,10 @@ describe("TerminalManager", () => { expect(manager.listDirectories()).toEqual([]); }); - it("returns all cwds that have ever had terminals", async () => { + it("returns all cwds with active terminals", async () => { manager = createTerminalManager(); - await manager.getTerminals("/tmp"); - await manager.getTerminals("/home"); + await manager.createTerminal({ cwd: "/tmp" }); + await manager.createTerminal({ cwd: "/home" }); const dirs = manager.listDirectories(); expect(dirs).toContain("/tmp"); @@ -266,10 +265,10 @@ describe("TerminalManager", () => { describe("killAll", () => { it("kills all terminals and clears state", async () => { manager = createTerminalManager(); - const tmpTerminals = await manager.getTerminals("/tmp"); - const homeTerminals = await manager.getTerminals("/home"); - const tmpId = tmpTerminals[0].id; - const homeId = homeTerminals[0].id; + const tmpSession = await manager.createTerminal({ cwd: "/tmp" }); + const homeSession = await manager.createTerminal({ cwd: "/home" }); + const tmpId = tmpSession.id; + const homeId = homeSession.id; manager.killAll(); @@ -290,7 +289,7 @@ describe("TerminalManager", () => { }); }); - await manager.getTerminals("/tmp"); + await manager.createTerminal({ cwd: "/tmp" }); await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); expect(snapshots).toContainEqual({ @@ -315,8 +314,8 @@ describe("TerminalManager", () => { }); }); - const terminals = await manager.getTerminals("/tmp"); - manager.killTerminal(terminals[0].id); + const session = await manager.createTerminal({ cwd: "/tmp" }); + manager.killTerminal(session.id); expect(snapshots).toContainEqual({ cwd: "/tmp", diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index a30ee697a..a0ccebc49 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -35,7 +35,6 @@ 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("/")) { @@ -135,28 +134,7 @@ export function createTerminalManager(): TerminalManager { async getTerminals(cwd: string): Promise { assertAbsolutePath(cwd); - 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({ - cwd, - name: "Terminal 1", - ...(inheritedEnv ? { env: inheritedEnv } : {}), - }) - ); - const created = [session]; - terminalsByCwd.set(cwd, created); - knownDirectories.add(cwd); - emitTerminalsChanged({ cwd }); - return created; - } - - return []; + return terminalsByCwd.get(cwd) ?? []; }, async createTerminal(options: { @@ -166,7 +144,6 @@ 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); @@ -203,14 +180,13 @@ export function createTerminalManager(): TerminalManager { }, listDirectories(): string[] { - return Array.from(knownDirectories); + return Array.from(terminalsByCwd.keys()); }, killAll(): void { for (const id of Array.from(terminalsById.keys())) { removeSessionById(id, { kill: true }); } - knownDirectories.clear(); }, subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {