diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index 71254dfcd..034dc3eb0 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -1,11 +1,13 @@ import { test, expect, type Page } from "./fixtures"; import { createAgent, + createAgentInRepo, ensureHostSelected, gotoHome, setWorkingDirectory, } from "./helpers/app"; import { createTempGitRepo } from "./helpers/workspace"; +import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; function visibleTestId(page: Page, testId: string) { return page.locator(`[data-testid="${testId}"]:visible`).first(); @@ -171,6 +173,19 @@ async function runTerminalCommand(page: Page, command: string, expectedText: str }); } +async function runTerminalCommandAndWaitForBuffer( + page: Page, + command: string, + expectedText: string +): Promise { + const surface = visibleTestId(page, "terminal-surface"); + await expect(surface).toBeVisible({ timeout: 30000 }); + await surface.click({ force: true }); + await page.keyboard.type(command, { delay: 1 }); + await page.keyboard.press("Enter"); + await expectCurrentTerminalBufferToContain(page, expectedText); +} + async function runTerminalCommandWithPreEnterEcho( page: Page, command: string, @@ -209,17 +224,21 @@ async function readCurrentTerminalBuffer(page: Page): Promise { }; }).__paseoTerminal; - const lineCount = terminal?.buffer?.active?.length ?? 0; - const getLine = terminal?.buffer?.active?.getLine; - if (!getLine || lineCount <= 0) { + const buffer = terminal?.buffer?.active; + const lineCount = buffer?.length ?? 0; + if (!buffer || typeof buffer.getLine !== "function" || lineCount <= 0) { return ""; } const lines: string[] = []; for (let index = 0; index < lineCount; index += 1) { - let line: { translateToString: (trimRight?: boolean) => string } | null = null; + let line: + | { + translateToString: (trimRight?: boolean) => string; + } + | null = null; try { - line = getLine(index); + line = buffer.getLine(index); } catch { return ""; } @@ -342,6 +361,48 @@ test("Terminals tab creates multiple terminals and streams command output", asyn } }); +test("new terminal does not inherit output from the previously selected terminal", async ({ + page, +}) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-output-isolation-"); + + try { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + await createAgentInRepo(page, { + directory: repo.path, + prompt: "hello", + }); + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: repo.path, + }); + await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({ + timeout: 30000, + }); + + await page.getByTestId("workspace-new-terminal-tab").first().click(); + await waitForTerminalAttachToSettle(page); + const firstMarker = `terminal-isolation-one-${Date.now()}`; + await runTerminalCommandAndWaitForBuffer(page, `echo ${firstMarker}`, firstMarker); + + await page.getByTestId("workspace-new-terminal-tab").first().click(); + await waitForTerminalAttachToSettle(page); + + await expectCurrentTerminalBufferNotToContain(page, firstMarker); + + const secondMarker = `terminal-isolation-two-${Date.now()}`; + await runTerminalCommandAndWaitForBuffer(page, `echo ${secondMarker}`, secondMarker); + await expectCurrentTerminalBufferNotToContain(page, firstMarker); + } finally { + await repo.cleanup(); + } +}); + test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => { const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-"); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d44ddeeef..8771985d3 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -32,8 +32,7 @@ import { HorizontalScrollProvider, useHorizontalScrollOptional, } from "@/contexts/horizontal-scroll-context"; -import { getIsTauri, getIsTauriMac } from "@/constants/layout"; -import { useTrafficLightPadding } from "@/utils/tauri-window"; +import { getIsTauri } from "@/constants/layout"; import { CommandCenter } from "@/components/command-center"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; @@ -276,15 +275,11 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { ] ); - // When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights - const trafficLightPadding = useTrafficLightPadding(); - const needsTrafficLightPadding = !isMobile && !isOpen && getIsTauriMac(); - const content = ( {!isMobile && chromeEnabled && } - + {children} diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 9654baa4f..f7b654060 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -1,6 +1,9 @@ import { RefreshControl } from "react-native"; import { useCallback, useState } from "react"; -import DraggableFlatList, { type RenderItemParams } from "react-native-draggable-flatlist"; +import DraggableFlatList, { + NestableDraggableFlatList, + type RenderItemParams, +} from "react-native-draggable-flatlist"; import { useUnistyles } from "react-native-unistyles"; import type { DraggableListProps, @@ -30,63 +33,56 @@ export function DraggableList({ simultaneousGestureRef, waitFor, onDragBegin: onDragBeginProp, - onDragIntent: onDragIntentProp, - onDragRelease: onDragReleaseProp, - nestable: _nestable = false, + nestable = false, }: DraggableListProps) { const { theme } = useUnistyles(); const [isDragging, setIsDragging] = useState(false); + // Pass the ref directly to DraggableFlatList - it handles gesture + // coordination internally for nestable lists. const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined; const handleRenderItem = useCallback( ({ item, drag, isActive, getIndex }: RenderItemParams) => { const index = getIndex() ?? 0; - const itemKey = keyExtractor(item, index); - const dragWithLog = () => { - console.log("[sidebar-dnd-debug] row drag() called", { testID, itemKey, index }); - onDragIntentProp?.(); - drag(); - }; const info: DraggableRenderItemInfo = { item, index, - drag: dragWithLog, + drag, isActive, }; return renderItem(info); }, - [keyExtractor, onDragIntentProp, renderItem, testID] + [renderItem] ); const handleDragEnd = useCallback( ({ data: newData }: { data: T[] }) => { - console.log("[sidebar-dnd-debug] list onDragEnd", { testID, count: newData.length }); setIsDragging(false); onDragEnd(newData); }, - [onDragEnd, testID] + [onDragEnd] ); const handleDragBegin = useCallback(() => { - console.log("[sidebar-dnd-debug] list onDragBegin", { testID }); setIsDragging(true); onDragBeginProp?.(); - }, [onDragBeginProp, testID]); + }, [onDragBeginProp]); const handleRelease = useCallback(() => { - console.log("[sidebar-dnd-debug] list onRelease", { testID }); setIsDragging(false); - onDragReleaseProp?.(); - }, [onDragReleaseProp, testID]); + }, []); const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing)); const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined); - const shouldShowRefreshControl = showRefreshControl; + const shouldShowRefreshControl = showRefreshControl && !nestable; + const ListComponent: typeof DraggableFlatList = (nestable + ? (NestableDraggableFlatList as any) + : DraggableFlatList) as any; return ( - ({ showsVerticalScrollIndicator={showsVerticalScrollIndicator} scrollEnabled={scrollEnabled} simultaneousHandlers={simultaneousHandlers} - activationDistance={6} + // Higher activation distance reduces accidental drag capture while nested + // lists are inside a scroll container. + activationDistance={20} onDragBegin={handleDragBegin} onRelease={handleRelease} // @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index ae3a7b0b4..2d3e8b16f 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -1,13 +1,15 @@ import type { ReactNode } from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE, + getIsTauriMac, } from "@/constants/layout"; -import { useTauriDragHandlers } from "@/utils/tauri-window"; +import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; +import { usePanelStore } from "@/stores/panel-store"; interface ScreenHeaderProps { left?: ReactNode; @@ -26,19 +28,29 @@ export function ScreenHeader({ leftStyle, rightStyle, }: ScreenHeaderProps) { + const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const trafficLightPadding = useTrafficLightPadding(); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; + const baseHorizontalPadding = theme.spacing[2]; + const collapsedSidebarTrafficLightInset = + !isMobile && !desktopAgentListOpen && getIsTauriMac() + ? trafficLightPadding.left + : 0; // On Tauri macOS, enable window dragging and double-click to maximize - // Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed const dragHandlers = useTauriDragHandlers(); return ( - + {left} {right} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ed34ba61f..91752053c 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -32,6 +32,14 @@ import { } from '@/utils/host-routes' const DESKTOP_SIDEBAR_WIDTH = 320 +const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__) + +function logLeftSidebarCloseGesture(event: string, details: Record): void { + if (!IS_DEV) { + return + } + console.log(`[LeftSidebarCloseGesture] ${event}`, details) +} interface LeftSidebarProps { selectedAgentId?: string @@ -253,6 +261,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr }) .onStart(() => { isGesturing.value = true + runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile }) }) .onUpdate((event) => { if (!isMobile) return @@ -270,6 +279,11 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr isGesturing.value = false if (!isMobile) return const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 + runOnJS(logLeftSidebarCloseGesture)('end', { + translationX: event.translationX, + velocityX: event.velocityX, + shouldClose, + }) if (shouldClose) { animateToClose() runOnJS(handleClose)() diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index e843bd7e6..1af1386d5 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -24,6 +24,7 @@ import { router, usePathname } 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 { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' @@ -52,6 +53,7 @@ import { useToast } from '@/contexts/toast-context' import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder' +import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration' import { confirmDialog } from '@/utils/confirm-dialog' const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees' @@ -160,6 +162,7 @@ function useLongPressDragInteraction(input: { }) { const didLongPressRef = useRef(false) const dragArmedRef = useRef(false) + const dragActivatedRef = useRef(false) const didStartDragRef = useRef(false) const scrollIntentRef = useRef(false) const menuOpenedRef = useRef(false) @@ -212,7 +215,8 @@ function useLongPressDragInteraction(input: { const armTimers = useCallback(() => { clearTimers() - const DRAG_ARM_DELAY_MS = 140 + const DRAG_ARM_DELAY_MS = 180 + const DRAG_ARM_STATIONARY_SLOP_PX = 4 const CONTEXT_MENU_DELAY_MS = 450 const CONTEXT_MENU_STATIONARY_SLOP_PX = 6 @@ -220,9 +224,27 @@ function useLongPressDragInteraction(input: { if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { return } + const start = touchStartRef.current + const current = touchCurrentRef.current ?? start + if (!start || !current) { + return + } + const dx = current.x - start.x + const dy = current.y - start.y + const distance = Math.sqrt(dx * dx + dy * dy) + if (distance > DRAG_ARM_STATIONARY_SLOP_PX) { + console.log('[sidebar-dnd-debug] drag arm cancelled (movement)', { + id: input.debugId, + distance, + }) + return + } dragArmedRef.current = true + dragActivatedRef.current = true + didLongPressRef.current = true console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId }) void Haptics.selectionAsync().catch(() => {}) + input.drag() }, DRAG_ARM_DELAY_MS) if (!input.menuController || Platform.OS === 'web') { @@ -256,14 +278,16 @@ function useLongPressDragInteraction(input: { const handleDragIntent = useCallback( (details: { dx: number; dy: number; distance: number }) => { + if (!dragActivatedRef.current) { + return + } didStartDragRef.current = true didLongPressRef.current = true clearTimers() - console.log('[sidebar-dnd-debug] drag intent detected', { id: input.debugId, ...details }) + console.log('[sidebar-dnd-debug] drag movement detected', { id: input.debugId, ...details }) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}) - input.drag() }, - [clearTimers, input] + [clearTimers, input.debugId] ) const handleScrollIntent = useCallback( @@ -276,9 +300,19 @@ function useLongPressDragInteraction(input: { [clearTimers, input.debugId] ) + const handleSwipeIntent = useCallback( + (details: { dx: number; dy: number; distance: number }) => { + didLongPressRef.current = true + clearTimers() + console.log('[sidebar-dnd-debug] swipe intent detected', { id: input.debugId, ...details }) + }, + [clearTimers, input.debugId] + ) + const handlePressIn = useCallback((event: GestureResponderEvent) => { didLongPressRef.current = false dragArmedRef.current = false + dragActivatedRef.current = false didStartDragRef.current = false scrollIntentRef.current = false menuOpenedRef.current = false @@ -301,7 +335,7 @@ function useLongPressDragInteraction(input: { const handleTouchMove = useCallback( (event: any) => { const start = touchStartRef.current - if (!start || didStartDragRef.current) { + if (!start || didStartDragRef.current || menuOpenedRef.current) { return } @@ -311,30 +345,34 @@ function useLongPressDragInteraction(input: { if (typeof x !== 'number' || typeof y !== 'number') { return } - touchCurrentRef.current = { x, y } - const dx = x - start.x - const dy = y - start.y - const absDx = Math.abs(dx) - const absDy = Math.abs(dy) + + const current = { x, y } + touchCurrentRef.current = current + const dx = current.x - start.x + const dy = current.y - start.y const distance = Math.sqrt(dx * dx + dy * dy) + const decision = decideLongPressMove({ + dragArmed: dragArmedRef.current, + didStartDrag: didStartDragRef.current, + startPoint: start, + currentPoint: current, + }) - const SCROLL_INTENT_SLOP_PX = 8 - const DRAG_START_SLOP_PX = 6 - - if (!scrollIntentRef.current && absDy > absDx && absDy > SCROLL_INTENT_SLOP_PX) { + if (decision === 'vertical_scroll') { handleScrollIntent({ dx, dy, distance }) return } - if (scrollIntentRef.current) { + if (decision === 'horizontal_swipe' || decision === 'cancel_long_press') { + handleSwipeIntent({ dx, dy, distance }) return } - if (dragArmedRef.current && distance >= DRAG_START_SLOP_PX) { + if (decision === 'start_drag') { handleDragIntent({ dx, dy, distance }) } }, - [handleDragIntent, handleScrollIntent] + [handleDragIntent, handleScrollIntent, handleSwipeIntent] ) const handlePressOut = useCallback(() => { @@ -343,11 +381,13 @@ function useLongPressDragInteraction(input: { id: input.debugId, didLongPress: didLongPressRef.current, didStartDrag: didStartDragRef.current, + dragActivated: dragActivatedRef.current, scrollIntent: scrollIntentRef.current, dragArmed: dragArmedRef.current, menuOpened: menuOpenedRef.current, }) dragArmedRef.current = false + dragActivatedRef.current = false touchStartRef.current = null touchCurrentRef.current = null }, [clearTimers, input.debugId]) @@ -371,10 +411,9 @@ function ProjectHeaderRow({ isDragging, dragHandleProps, }: ProjectHeaderRowProps) { - const menuController = useContextMenu() const interaction = useLongPressDragInteraction({ drag, - menuController, + menuController: null, debugId: `project:${project.projectKey}`, }) @@ -387,8 +426,7 @@ function ProjectHeaderRow({ }, [interaction.didLongPressRef, onToggle]) const trigger = ( - [ styles.projectRow, isDragging && styles.projectRowDragging, @@ -407,12 +445,6 @@ function ProjectHeaderRow({ ref={dragHandleProps?.setActivatorNodeRef as any} style={styles.projectRowLeft} > - {collapsed ? ( - - ) : ( - - )} - {iconDataUri ? ( ) : ( @@ -424,29 +456,19 @@ function ProjectHeaderRow({ {displayName} + + {collapsed ? ( + + ) : ( + + )} - + ) return trigger } -function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) { - return ( - - - - - {props.collapsed ? 'Expand project' : 'Collapse project'} - - - - ) -} - function WorkspaceRowInner({ workspace, selected, @@ -550,7 +572,7 @@ function WorkspaceRowInner({ ) } -function WorkspaceRowWithMenu({ +function WorkspaceRowWithMenuContent({ workspace, selected, shortcutNumber, @@ -644,6 +666,41 @@ function WorkspaceRowWithMenu({ ) } +function WorkspaceRowWithMenu({ + workspace, + selected, + shortcutNumber, + showShortcutBadge, + onPress, + drag, + isDragging, + dragHandleProps, +}: { + workspace: SidebarWorkspaceEntry + selected: boolean + shortcutNumber: number | null + showShortcutBadge: boolean + onPress: () => void + drag: () => void + isDragging: boolean + dragHandleProps?: DraggableListDragHandleProps +}) { + return ( + + + + ) +} + function WorkspaceRowPlain({ workspace, selected, @@ -714,18 +771,16 @@ function WorkspaceRow({ } return ( - - - + ) } @@ -742,8 +797,6 @@ function ProjectBlock({ onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, - onAnyDragBegin, - onAnyDragEnd, drag, isDragging, dragHandleProps, @@ -761,8 +814,6 @@ function ProjectBlock({ onToggleCollapsed: () => void onWorkspacePress?: () => void onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void - onAnyDragBegin: () => void - onAnyDragEnd: () => void drag: () => void isDragging: boolean dragHandleProps?: DraggableListDragHandleProps @@ -811,7 +862,7 @@ function ProjectBlock({ return ( - workspace.workspaceKey} renderItem={renderWorkspace} - onDragIntent={onAnyDragBegin} - onDragBegin={onAnyDragBegin} - onDragRelease={onAnyDragEnd} - onDragEnd={(workspaces) => { - onWorkspaceReorder(project.projectKey, workspaces) - onAnyDragEnd() - }} + onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)} scrollEnabled={false} useDragHandle nestable={useNestable} @@ -857,9 +902,9 @@ export function SidebarWorkspaceList({ parentGestureRef, }: SidebarWorkspaceListProps) { const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const isNative = Platform.OS !== 'web' const pathname = usePathname() const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) - const [outerScrollEnabled, setOuterScrollEnabled] = useState(true) const isTauri = getIsTauri() const altDown = useKeyboardShortcutsStore((state) => state.altDown) const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) @@ -1008,16 +1053,6 @@ export function SidebarWorkspaceList({ }) }, []) - const lockOuterScrollForDrag = useCallback((source: string) => { - console.log('[sidebar-dnd-debug] outer scroll locked', { source }) - setOuterScrollEnabled(false) - }, []) - - const unlockOuterScrollForDrag = useCallback((source: string) => { - console.log('[sidebar-dnd-debug] outer scroll unlocked', { source }) - setOuterScrollEnabled(true) - }, []) - const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { if (!serverId) { @@ -1091,12 +1126,10 @@ export function SidebarWorkspaceList({ onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)} onWorkspacePress={onWorkspacePress} onWorkspaceReorder={handleWorkspaceReorder} - onAnyDragBegin={() => lockOuterScrollForDrag(`workspace:${item.projectKey}`)} - onAnyDragEnd={() => unlockOuterScrollForDrag(`workspace:${item.projectKey}`)} drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} - useNestable={false} + useNestable={isNative} /> ) }, @@ -1111,8 +1144,7 @@ export function SidebarWorkspaceList({ shortcutModel.shortcutIndexByWorkspaceKey, showShortcutBadges, toggleProjectCollapsed, - lockOuterScrollForDrag, - unlockOuterScrollForDrag, + isNative, ] ) @@ -1126,16 +1158,10 @@ export function SidebarWorkspaceList({ data={projects} keyExtractor={(project) => project.projectKey} renderItem={renderProject} - onDragIntent={() => lockOuterScrollForDrag('project-list-intent')} - onDragBegin={() => lockOuterScrollForDrag('project-list-begin')} - onDragRelease={() => unlockOuterScrollForDrag('project-list-release')} - onDragEnd={(reorderedProjects) => { - handleProjectDragEnd(reorderedProjects) - unlockOuterScrollForDrag('project-list-end') - }} + onDragEnd={handleProjectDragEnd} scrollEnabled={false} useDragHandle - nestable={false} + nestable={isNative} simultaneousGestureRef={parentGestureRef} containerStyle={styles.projectListContainer} /> @@ -1146,18 +1172,31 @@ export function SidebarWorkspaceList({ return ( - - console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled }) - } - testID="sidebar-project-workspace-list-scroll" - > - {content} - + {isNative ? ( + + console.log('[sidebar-dnd-debug] outer scroll begin') + } + testID="sidebar-project-workspace-list-scroll" + > + {content} + + ) : ( + + console.log('[sidebar-dnd-debug] outer scroll begin') + } + testID="sidebar-project-workspace-list-scroll" + > + {content} + + )} ) } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 36e5fa9cf..e26bfd1cd 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -29,10 +29,8 @@ import { import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; import { - getTerminalOutputSession, - releaseTerminalOutputSession, - retainTerminalOutputSession, -} from "@/terminal/runtime/terminal-output-session"; + getWorkspaceTerminalSession, +} from "@/terminal/runtime/workspace-terminal-session"; import { TerminalStreamController, type TerminalStreamControllerStatus, @@ -162,14 +160,15 @@ export function TerminalPane({ const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); - const outputSession = useMemo( + const workspaceTerminalSession = useMemo( () => - getTerminalOutputSession({ + getWorkspaceTerminalSession({ scopeKey, maxOutputChars: MAX_OUTPUT_CHARS, }), [scopeKey] ); + const outputSession = workspaceTerminalSession.outputSession; const subscribeOutputSession = useCallback( (listener: () => void) => outputSession.subscribe(listener), [outputSession] @@ -178,17 +177,26 @@ export function TerminalPane({ () => outputSession.getState(), [outputSession] ); - useEffect(() => { - retainTerminalOutputSession({ scopeKey }); - return () => { - releaseTerminalOutputSession({ scopeKey }); - }; - }, [scopeKey]); const outputState = useSyncExternalStore( subscribeOutputSession, getOutputSessionState, getOutputSessionState ); + const selectedOutputState = useMemo(() => { + if (outputState.selectedTerminalId === selectedTerminalId) { + return outputState; + } + + return { + ...outputState, + selectedTerminalId, + snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }), + snapshotSequence: 0, + chunkText: "", + chunkSequence: 0, + chunkReplay: false, + }; + }, [outputSession, outputState, selectedTerminalId]); const [activeStream, setActiveStream] = useState<{ terminalId: string; streamId: number; @@ -503,6 +511,7 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, getPreferredSize: () => lastReportedSizeRef.current, + resumeOffsets: workspaceTerminalSession.resumeOffsets, onChunk: ({ terminalId, text, replay }) => { outputSession.append({ terminalId, text, replay }); }, @@ -523,7 +532,14 @@ export function TerminalPane({ streamControllerRef.current = null; } }; - }, [client, handleStreamControllerStatus, isConnected, isScreenFocused, outputSession]); + }, [ + client, + handleStreamControllerStatus, + isConnected, + isScreenFocused, + outputSession, + workspaceTerminalSession.resumeOffsets, + ]); useEffect(() => { pendingTerminalInputRef.current = []; @@ -1022,11 +1038,11 @@ export function TerminalPane({ contentInsetAdjustmentBehavior: "never", }} streamKey={`${scopeKey}:${selectedTerminal.id}`} - initialOutputText={outputState.snapshotText} - initialOutputChunkSequence={outputState.snapshotSequence} - outputChunkText={outputState.chunkText} - outputChunkSequence={outputState.chunkSequence} - outputChunkReplay={outputState.chunkReplay} + initialOutputText={selectedOutputState.snapshotText} + initialOutputChunkSequence={selectedOutputState.snapshotSequence} + outputChunkText={selectedOutputState.chunkText} + outputChunkSequence={selectedOutputState.chunkSequence} + outputChunkReplay={selectedOutputState.chunkReplay} testId="terminal-surface" xtermTheme={xtermTheme} swipeGesturesEnabled={swipeGesturesEnabled} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 45cd14e4e..cdc963e41 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -45,6 +45,12 @@ export interface ComboboxProps { options: ComboboxOption[] value: string onSelect: (id: string) => void + renderOption?: (input: { + option: ComboboxOption + selected: boolean + active: boolean + onPress: () => void + }) => ReactElement onSearchQueryChange?: (query: string) => void searchable?: boolean placeholder?: string @@ -204,6 +210,7 @@ export function Combobox({ options, value, onSelect, + renderOption, onSearchQueryChange, searchable = true, placeholder = 'Search...', @@ -568,15 +575,26 @@ export function Combobox({ <> {orderedVisibleOptions.length > 0 ? ( orderedVisibleOptions.map((opt, index) => ( - handleSelect(opt.id)} - /> + renderOption ? ( + + {renderOption({ + option: opt, + selected: opt.id === value, + active: index === activeIndex, + onPress: () => handleSelect(opt.id), + })} + + ) : ( + handleSelect(opt.id)} + /> + ) )) ) : ( {emptyText} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 01e75147f..24ce8c8e9 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,10 +1,8 @@ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; -import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native"; +import { Plus, SquareTerminal, X } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; -import { ClaudeIcon } from "@/components/icons/claude-icon"; -import { CodexIcon } from "@/components/icons/codex-icon"; import { ContextMenu, ContextMenuContent, @@ -14,9 +12,11 @@ import { } from "@/components/ui/context-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; +import { + deriveWorkspaceTabPresentation, + WorkspaceTabIcon, +} from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { getStatusDotColor } from "@/utils/status-dot-color"; import { encodeFilePathForPathSegment } from "@/utils/host-routes"; import type { Agent } from "@/stores/session-store"; @@ -158,52 +158,7 @@ export function WorkspaceDesktopTabsRow({ const showLabel = layoutItem?.showLabel ?? true; const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length; const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : ""; - 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.kind === "draft" ? ( - - ) : tab.kind === "file" ? ( - - ) : ( - - ); + const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent }); const contextMenuTestId = `workspace-tab-context-${tab.key}`; @@ -246,9 +201,11 @@ export function WorkspaceDesktopTabsRow({ ref={dragHandleProps?.setActivatorNodeRef} style={styles.tabHandle} > - {icon} + + + {showLabel ? ( - tab.kind === "agent" && tab.titleState === "loading" ? ( + presentation.titleState === "loading" ? ( ({ 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, }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 81c6420f0..72fa6d947 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -11,16 +11,12 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import { - Bot, ChevronDown, - FileText, Folder, GitBranch, PanelRight, Plus, - Pencil, SquareTerminal, - Terminal, } from "lucide-react-native"; import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -28,8 +24,6 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; import { Combobox } from "@/components/ui/combobox"; -import { ClaudeIcon } from "@/components/icons/claude-icon"; -import { CodexIcon } from "@/components/icons/codex-icon"; import { Tooltip, TooltipContent, @@ -58,6 +52,7 @@ import { } from "@/utils/host-routes"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, type CheckoutStatusPayload, @@ -66,14 +61,17 @@ 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"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { + deriveWorkspaceTabPresentation, + WorkspaceTabIcon, + WorkspaceTabOptionRow, +} from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceHeader, @@ -145,6 +143,13 @@ function WorkspaceScreenContent({ const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; const normalizedWorkspaceId = normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? ""; + const workspaceTerminalScopeKey = + normalizedServerId && normalizedWorkspaceId + ? `${normalizedServerId}:${normalizedWorkspaceId}` + : null; + useWorkspaceTerminalSessionRetention({ + scopeKey: workspaceTerminalScopeKey, + }); const queryClient = useQueryClient(); const { client, isConnected } = useHostRuntimeSession(normalizedServerId); @@ -702,24 +707,32 @@ function WorkspaceScreenContent({ }, [tabs]); const activeTabKey = activeTabId ?? ""; + const tabPresentationsByKey = useMemo(() => { + const map = new Map>(); + for (const tab of tabs) { + const agent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; + map.set(tab.key, deriveWorkspaceTabPresentation({ tab, agent })); + } + return map; + }, [agentsById, tabs]); + const activeTabPresentation = tabPresentationsByKey.get(activeTabKey) ?? null; const tabSwitcherOptions = useMemo( () => tabs.map((tab) => ({ id: tab.key, - label: tab.kind === "agent" && tab.titleState === "loading" ? "Loading..." : tab.label, + label: tabPresentationsByKey.get(tab.key)?.titleState === "loading" ? "Loading..." : tab.label, description: tab.subtitle, })), - [tabs] + [tabPresentationsByKey, tabs] ); const activeTabLabel = useMemo(() => { - const active = tabs.find((tab) => tab.key === activeTabKey); - if (active?.kind === "agent" && active.titleState === "loading") { + if (activeTabPresentation?.titleState === "loading") { return "Loading..."; } - return active?.label ?? "Select tab"; - }, [activeTabKey, tabs]); + return activeTabPresentation?.label ?? "Select tab"; + }, [activeTabPresentation]); const handleCreateDraftTab = useCallback(() => { if (!normalizedServerId || !normalizedWorkspaceId) { @@ -1322,69 +1335,9 @@ function WorkspaceScreenContent({ > - {(() => { - const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null; - if (!activeDescriptor) { - return ; - } - - if (activeDescriptor.kind === "terminal") { - return ; - } - - if (activeDescriptor.kind === "file") { - return ; - } - - if (activeDescriptor.kind === "draft") { - return ; - } - - if (activeDescriptor.kind !== "agent") { - 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} - - ); - })()} + {activeTabPresentation ? ( + + ) : null} @@ -1456,6 +1409,23 @@ function WorkspaceScreenContent({ open={isTabSwitcherOpen} onOpenChange={setIsTabSwitcherOpen} anchorRef={tabSwitcherAnchorRef} + renderOption={({ option, selected, active, onPress }) => { + const tab = tabByKey.get(option.id); + if (!tab) { + return ; + } + const presentation = + tabPresentationsByKey.get(option.id) ?? + deriveWorkspaceTabPresentation({ tab }); + return ( + + ); + }} /> ) : ( @@ -1710,22 +1680,6 @@ 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, }, diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx new file mode 100644 index 000000000..de6035afd --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -0,0 +1,196 @@ +import type { ReactElement } from "react"; +import { Pressable, Text, View } from "react-native"; +import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ClaudeIcon } from "@/components/icons/claude-icon"; +import { CodexIcon } from "@/components/icons/codex-icon"; +import type { Agent } from "@/stores/session-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + deriveSidebarStateBucket, + type SidebarStateBucket, +} from "@/utils/sidebar-agent-state"; +import { getStatusDotColor } from "@/utils/status-dot-color"; + +export type WorkspaceTabPresentation = { + key: string; + kind: WorkspaceTabDescriptor["kind"]; + label: string; + subtitle: string; + titleState: "ready" | "loading"; + provider: Agent["provider"] | null; + statusBucket: SidebarStateBucket | null; +}; + +export function deriveWorkspaceTabPresentation(input: { + tab: WorkspaceTabDescriptor; + agent?: Agent | null; +}): WorkspaceTabPresentation { + const { tab, agent = null } = input; + return { + key: tab.key, + kind: tab.kind, + label: tab.label, + subtitle: tab.subtitle, + titleState: tab.kind === "agent" ? tab.titleState : "ready", + provider: tab.kind === "agent" ? tab.provider : null, + statusBucket: + tab.kind === "agent" && agent + ? deriveSidebarStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + }) + : null, + }; +} + +type WorkspaceTabIconProps = { + presentation: WorkspaceTabPresentation; + active?: boolean; + size?: number; + statusDotBorderColor?: string; +}; + +export function WorkspaceTabIcon({ + presentation, + active = false, + size = 14, + statusDotBorderColor, +}: WorkspaceTabIconProps): ReactElement { + const { theme } = useUnistyles(); + const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted; + const statusDotColor = + presentation.statusBucket === null + ? null + : getStatusDotColor({ + theme, + bucket: presentation.statusBucket, + showDoneAsInactive: false, + }); + + if (presentation.kind === "agent") { + return ( + + {presentation.provider === "claude" ? ( + + ) : presentation.provider === "codex" ? ( + + ) : ( + + )} + {statusDotColor ? ( + + ) : null} + + ); + } + + if (presentation.kind === "draft") { + return ; + } + + if (presentation.kind === "file") { + return ; + } + + return ; +} + +type WorkspaceTabOptionRowProps = { + presentation: WorkspaceTabPresentation; + selected: boolean; + active: boolean; + onPress: () => void; +}; + +export function WorkspaceTabOptionRow({ + presentation, + selected, + active, + onPress, +}: WorkspaceTabOptionRowProps): ReactElement { + const { theme } = useUnistyles(); + return ( + [ + styles.optionRow, + (hovered || pressed || active) && styles.optionRowActive, + ]} + > + + + + + + {presentation.titleState === "loading" ? "Loading..." : presentation.label} + + + {selected ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + agentIconWrapper: { + position: "relative", + alignItems: "center", + justifyContent: "center", + }, + statusDot: { + position: "absolute", + right: -2, + bottom: -2, + width: 7, + height: 7, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + optionRow: { + flexDirection: "row", + alignItems: "center", + minHeight: 36, + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: 0, + marginHorizontal: theme.spacing[1], + marginBottom: theme.spacing[1], + }, + optionRowActive: { + backgroundColor: theme.colors.surface1, + }, + optionLeadingSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + optionContent: { + flex: 1, + flexShrink: 1, + }, + optionLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + optionTrailingSlot: { + width: 16, + alignItems: "center", + justifyContent: "center", + marginLeft: "auto", + }, +})); diff --git a/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts b/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts new file mode 100644 index 000000000..291be5ba2 --- /dev/null +++ b/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts @@ -0,0 +1,20 @@ +import { useEffect } from "react"; +import { + releaseWorkspaceTerminalSession, + retainWorkspaceTerminalSession, +} from "@/terminal/runtime/workspace-terminal-session"; + +export function useWorkspaceTerminalSessionRetention(input: { + scopeKey: string | null; +}): void { + useEffect(() => { + if (!input.scopeKey) { + return; + } + + retainWorkspaceTerminalSession({ scopeKey: input.scopeKey }); + return () => { + releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! }); + }; + }, [input.scopeKey]); +} diff --git a/packages/app/src/terminal/runtime/terminal-output-session.ts b/packages/app/src/terminal/runtime/terminal-output-session.ts index bb06ad0bd..5fa31148a 100644 --- a/packages/app/src/terminal/runtime/terminal-output-session.ts +++ b/packages/app/src/terminal/runtime/terminal-output-session.ts @@ -44,6 +44,10 @@ export type TerminalOutputSessionConsumeInput = { sequence: number; }; +export type TerminalOutputSessionReadSnapshotInput = { + terminalId: string | null; +}; + export class TerminalOutputSession { private readonly listeners = new Set<() => void>(); private readonly outputPump: TerminalOutputPump; @@ -132,6 +136,10 @@ export class TerminalOutputSession { this.outputPump.append(input); } + readSnapshot(input: TerminalOutputSessionReadSnapshotInput): string { + return this.outputPump.readSnapshot(input); + } + clearTerminal(input: { terminalId: string }): void { this.outputPump.clearTerminal(input); if (this.state.selectedTerminalId !== input.terminalId) { diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index 96a450430..023de3c39 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -5,6 +5,7 @@ import { type TerminalStreamControllerAttachPayload, type TerminalStreamControllerChunk, type TerminalStreamControllerClient, + type TerminalStreamControllerResumeOffsets, type TerminalStreamControllerStatus, } from "./terminal-stream-controller"; @@ -112,6 +113,7 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { function createControllerHarness(input?: { client?: FakeTerminalStreamClient; + resumeOffsets?: TerminalStreamControllerResumeOffsets; }): { client: FakeTerminalStreamClient; chunks: Array<{ terminalId: string; text: string }>; @@ -127,6 +129,7 @@ function createControllerHarness(input?: { const controller = new TerminalStreamController({ client, getPreferredSize: () => ({ rows: 24, cols: 80 }), + resumeOffsets: input?.resumeOffsets, onChunk: (chunk) => { chunks.push({ terminalId: chunk.terminalId, @@ -518,4 +521,69 @@ describe("terminal-stream-controller", () => { text: "live", }); }); + + it("reuses external resume offsets across controller recreation", async () => { + const client = new FakeTerminalStreamClient(); + const resumeOffsetByTerminalId = new Map(); + const resumeOffsets: TerminalStreamControllerResumeOffsets = { + get: ({ terminalId }) => resumeOffsetByTerminalId.get(terminalId), + set: ({ terminalId, offset }) => { + resumeOffsetByTerminalId.set(terminalId, offset); + }, + clear: ({ terminalId }) => { + resumeOffsetByTerminalId.delete(terminalId); + }, + prune: ({ terminalIds }) => { + const terminalIdSet = new Set(terminalIds); + for (const terminalId of Array.from(resumeOffsetByTerminalId.keys())) { + if (!terminalIdSet.has(terminalId)) { + resumeOffsetByTerminalId.delete(terminalId); + } + } + }, + }; + + client.nextAttachResponses.push({ + streamId: 81, + currentOffset: 0, + reset: false, + error: null, + }); + + const firstHarness = createControllerHarness({ + client, + resumeOffsets, + }); + firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" }); + await flushAsyncWork(); + + client.emitChunk({ + streamId: 81, + offset: 0, + endOffset: 2, + data: "ok", + }); + await flushAsyncWork(); + firstHarness.controller.dispose(); + + client.nextAttachResponses.push({ + streamId: 82, + currentOffset: 2, + reset: false, + error: null, + }); + + const secondHarness = createControllerHarness({ + client, + resumeOffsets, + }); + secondHarness.controller.setTerminal({ terminalId: "term-shared-resume" }); + await flushAsyncWork(); + + expect(client.attachCalls.at(-1)?.options).toEqual({ + resumeOffset: 2, + rows: 24, + cols: 80, + }); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 8b2f61536..7920863e8 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -2,6 +2,7 @@ import { getTerminalAttachRetryDelayMs, getTerminalResumeOffset, isTerminalAttachRetryableError, + type TerminalResumeOffsetStore, updateTerminalResumeOffset, waitForDuration, withPromiseTimeout, @@ -51,9 +52,15 @@ export type TerminalStreamControllerStatus = { error: string | null; }; +export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & { + clear: (input: { terminalId: string }) => void; + prune: (input: { terminalIds: string[] }) => void; +}; + export type TerminalStreamControllerOptions = { client: TerminalStreamControllerClient; getPreferredSize: () => TerminalStreamControllerSize | null; + resumeOffsets?: TerminalStreamControllerResumeOffsets; onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void; onReset?: (input: { terminalId: string }) => void; onStatusChange?: (status: TerminalStreamControllerStatus) => void; @@ -84,8 +91,8 @@ const DEFAULT_ATTACH_TIMEOUT_MS = 12_000; const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…"; export class TerminalStreamController { - private readonly resumeOffsetByTerminalId = new Map(); private readonly exitedStreamsWhileAttaching = new Set(); + private readonly internalResumeOffsetByTerminalId = new Map(); private selectedTerminalId: string | null = null; private activeStream: TerminalStreamControllerActiveStream | null = null; private attachGeneration = 0; @@ -201,10 +208,14 @@ export class TerminalStreamController { } pruneResumeOffsets(input: { terminalIds: string[] }): void { + if (this.options.resumeOffsets) { + this.options.resumeOffsets.prune(input); + return; + } const terminalIdSet = new Set(input.terminalIds); - for (const terminalId of Array.from(this.resumeOffsetByTerminalId.keys())) { + for (const terminalId of Array.from(this.internalResumeOffsetByTerminalId.keys())) { if (!terminalIdSet.has(terminalId)) { - this.resumeOffsetByTerminalId.delete(terminalId); + this.internalResumeOffsetByTerminalId.delete(terminalId); } } } @@ -217,7 +228,7 @@ export class TerminalStreamController { this.attachGeneration += 1; this.selectedTerminalId = null; void this.detachActiveStream({ shouldDetach: true }); - this.resumeOffsetByTerminalId.clear(); + this.internalResumeOffsetByTerminalId.clear(); this.updateStatus({ terminalId: null, streamId: null, @@ -260,7 +271,7 @@ export class TerminalStreamController { const preferredSize = this.options.getPreferredSize(); const resumeOffset = getTerminalResumeOffset({ terminalId: input.terminalId, - resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + resumeOffsetStore: this.getResumeOffsetStore(), }); const attachPayload = await withTimeout({ promise: this.options.client.attachTerminalStream(input.terminalId, { @@ -276,7 +287,7 @@ export class TerminalStreamController { updateTerminalResumeOffset({ terminalId: input.terminalId, offset: attachPayload.currentOffset, - resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + resumeOffsetStore: this.getResumeOffsetStore(), }); if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) { @@ -324,7 +335,7 @@ export class TerminalStreamController { } if (attachPayload.reset) { - this.resumeOffsetByTerminalId.delete(input.terminalId); + this.options.resumeOffsets?.clear({ terminalId: input.terminalId }); this.options.onReset?.({ terminalId: input.terminalId }); } @@ -492,7 +503,7 @@ export class TerminalStreamController { updateTerminalResumeOffset({ terminalId: input.terminalId, offset: chunkEndOffset, - resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + resumeOffsetStore: this.getResumeOffsetStore(), }); const text = input.decoder.decode(input.chunk.data, { stream: true }); @@ -541,7 +552,7 @@ export class TerminalStreamController { updateTerminalResumeOffset({ terminalId: input.terminalId, offset: input.expectedOffset, - resumeOffsetByTerminalId: this.resumeOffsetByTerminalId, + resumeOffsetStore: this.getResumeOffsetStore(), }); this.attachGeneration += 1; @@ -634,4 +645,16 @@ export class TerminalStreamController { }); this.options.onStatusChange?.(status); } + + private getResumeOffsetStore(): TerminalResumeOffsetStore { + if (this.options.resumeOffsets) { + return this.options.resumeOffsets; + } + return { + get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId), + set: ({ terminalId, offset }) => { + this.internalResumeOffsetByTerminalId.set(terminalId, offset); + }, + }; + } } diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts new file mode 100644 index 000000000..36cae2b62 --- /dev/null +++ b/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { + getWorkspaceTerminalSession, + releaseWorkspaceTerminalSession, + retainWorkspaceTerminalSession, +} from "./workspace-terminal-session"; + +describe("workspace-terminal-session", () => { + it("returns the same workspace session instance for the same scope", () => { + const first = getWorkspaceTerminalSession({ + scopeKey: "workspace-a", + maxOutputChars: 1_000, + }); + const second = getWorkspaceTerminalSession({ + scopeKey: "workspace-a", + maxOutputChars: 50, + }); + + expect(second).toBe(first); + }); + + it("preserves resume offsets across repeated lookups", () => { + const first = getWorkspaceTerminalSession({ + scopeKey: "workspace-resume", + maxOutputChars: 1_000, + }); + first.resumeOffsets.set({ + terminalId: "term-1", + offset: 42, + }); + + const second = getWorkspaceTerminalSession({ + scopeKey: "workspace-resume", + maxOutputChars: 1_000, + }); + + expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42); + }); + + it("evicts workspace terminal session state when the workspace retain count returns to zero", () => { + const scopeKey = "workspace-release"; + const first = getWorkspaceTerminalSession({ + scopeKey, + maxOutputChars: 1_000, + }); + first.resumeOffsets.set({ + terminalId: "term-1", + offset: 128, + }); + + retainWorkspaceTerminalSession({ scopeKey }); + releaseWorkspaceTerminalSession({ scopeKey }); + + const second = getWorkspaceTerminalSession({ + scopeKey, + maxOutputChars: 1_000, + }); + + expect(second).not.toBe(first); + expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined(); + }); +}); diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.ts new file mode 100644 index 000000000..ba2d53b38 --- /dev/null +++ b/packages/app/src/terminal/runtime/workspace-terminal-session.ts @@ -0,0 +1,103 @@ +import { + getTerminalOutputSession, + releaseTerminalOutputSession, + retainTerminalOutputSession, + type TerminalOutputSession, +} from "./terminal-output-session"; + +export type WorkspaceTerminalResumeOffsets = { + get: (input: { terminalId: string }) => number | undefined; + set: (input: { terminalId: string; offset: number }) => void; + clear: (input: { terminalId: string }) => void; + prune: (input: { terminalIds: string[] }) => void; +}; + +export type WorkspaceTerminalSession = { + scopeKey: string; + outputSession: TerminalOutputSession; + resumeOffsets: WorkspaceTerminalResumeOffsets; +}; + +type WorkspaceTerminalSessionRecord = { + resumeOffsetByTerminalId: Map; + session: WorkspaceTerminalSession; +}; + +const sessionsByScopeKey = new Map(); +const refCountByScopeKey = new Map(); + +function createResumeOffsets(input: { + resumeOffsetByTerminalId: Map; +}): WorkspaceTerminalResumeOffsets { + return { + get: ({ terminalId }) => { + const offset = input.resumeOffsetByTerminalId.get(terminalId); + if (typeof offset !== "number" || !Number.isFinite(offset)) { + return undefined; + } + return Math.max(0, Math.floor(offset)); + }, + set: ({ terminalId, offset }) => { + if (!Number.isFinite(offset)) { + return; + } + input.resumeOffsetByTerminalId.set(terminalId, Math.max(0, Math.floor(offset))); + }, + clear: ({ terminalId }) => { + input.resumeOffsetByTerminalId.delete(terminalId); + }, + prune: ({ terminalIds }) => { + const terminalIdSet = new Set(terminalIds); + for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) { + if (!terminalIdSet.has(terminalId)) { + input.resumeOffsetByTerminalId.delete(terminalId); + } + } + }, + }; +} + +export function getWorkspaceTerminalSession(input: { + scopeKey: string; + maxOutputChars: number; +}): WorkspaceTerminalSession { + const existing = sessionsByScopeKey.get(input.scopeKey); + if (existing) { + return existing.session; + } + + const resumeOffsetByTerminalId = new Map(); + const session: WorkspaceTerminalSession = { + scopeKey: input.scopeKey, + outputSession: getTerminalOutputSession({ + scopeKey: input.scopeKey, + maxOutputChars: input.maxOutputChars, + }), + resumeOffsets: createResumeOffsets({ + resumeOffsetByTerminalId, + }), + }; + + sessionsByScopeKey.set(input.scopeKey, { + resumeOffsetByTerminalId, + session, + }); + return session; +} + +export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void { + const current = refCountByScopeKey.get(input.scopeKey) ?? 0; + refCountByScopeKey.set(input.scopeKey, current + 1); + retainTerminalOutputSession(input); +} + +export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void { + releaseTerminalOutputSession(input); + const current = refCountByScopeKey.get(input.scopeKey) ?? 0; + if (current > 1) { + refCountByScopeKey.set(input.scopeKey, current - 1); + return; + } + refCountByScopeKey.delete(input.scopeKey); + sessionsByScopeKey.delete(input.scopeKey); +} diff --git a/packages/app/src/utils/sidebar-gesture-arbitration.test.ts b/packages/app/src/utils/sidebar-gesture-arbitration.test.ts index 603403475..e79e0b0ad 100644 --- a/packages/app/src/utils/sidebar-gesture-arbitration.test.ts +++ b/packages/app/src/utils/sidebar-gesture-arbitration.test.ts @@ -9,7 +9,7 @@ describe("decideLongPressMove", () => { it("keeps long press pending for small movement before long-press arm", () => { expect( decideLongPressMove({ - longPressArmed: false, + dragArmed: false, didStartDrag: false, startPoint: { x: 0, y: 0 }, currentPoint: { x: 3, y: 2 }, @@ -20,18 +20,51 @@ describe("decideLongPressMove", () => { it("cancels long press when movement exceeds cancel slop before arm", () => { expect( decideLongPressMove({ - longPressArmed: false, + dragArmed: false, didStartDrag: false, startPoint: { x: 0, y: 0 }, - currentPoint: { x: 12, y: 0 }, + currentPoint: { x: 8, y: 8 }, }) ).toBe("cancel_long_press"); }); + it("yields to vertical scroll before drag arm", () => { + expect( + decideLongPressMove({ + dragArmed: false, + didStartDrag: false, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: 2, y: 7 }, + }) + ).toBe("vertical_scroll"); + }); + + it("keeps diagonal motion neutral before drag arm", () => { + expect( + decideLongPressMove({ + dragArmed: false, + didStartDrag: false, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: 5, y: 7 }, + }) + ).toBe("none"); + }); + + it("yields to horizontal swipe before drag arm", () => { + expect( + decideLongPressMove({ + dragArmed: false, + didStartDrag: false, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: -10, y: 2 }, + }) + ).toBe("horizontal_swipe"); + }); + it("starts drag when movement exceeds drag slop after long-press arm", () => { expect( decideLongPressMove({ - longPressArmed: true, + dragArmed: true, didStartDrag: false, startPoint: { x: 0, y: 0 }, currentPoint: { x: 0, y: 9 }, @@ -42,7 +75,7 @@ describe("decideLongPressMove", () => { it("does nothing when drag already started", () => { expect( decideLongPressMove({ - longPressArmed: true, + dragArmed: true, didStartDrag: true, startPoint: { x: 0, y: 0 }, currentPoint: { x: 20, y: 20 }, @@ -75,4 +108,3 @@ describe("shouldOpenContextMenuOnPressOut", () => { ).toBe(false); }); }); - diff --git a/packages/app/src/utils/sidebar-gesture-arbitration.ts b/packages/app/src/utils/sidebar-gesture-arbitration.ts index 2b1f73f1d..c6d27b62c 100644 --- a/packages/app/src/utils/sidebar-gesture-arbitration.ts +++ b/packages/app/src/utils/sidebar-gesture-arbitration.ts @@ -1,14 +1,25 @@ -export type LongPressMoveDecision = "none" | "cancel_long_press" | "start_drag"; +export type LongPressMoveDecision = + | "none" + | "cancel_long_press" + | "vertical_scroll" + | "horizontal_swipe" + | "start_drag"; export function decideLongPressMove(input: { - longPressArmed: boolean; + dragArmed: boolean; didStartDrag: boolean; startPoint: { x: number; y: number } | null; currentPoint: { x: number; y: number }; cancelSlopPx?: number; + scrollSlopPx?: number; + swipeSlopPx?: number; + directionalDominanceRatio?: number; dragSlopPx?: number; }): LongPressMoveDecision { const cancelSlopPx = input.cancelSlopPx ?? 10; + const scrollSlopPx = input.scrollSlopPx ?? 6; + const swipeSlopPx = input.swipeSlopPx ?? 8; + const directionalDominanceRatio = input.directionalDominanceRatio ?? 1.5; const dragSlopPx = input.dragSlopPx ?? 8; if (!input.startPoint) { @@ -20,9 +31,19 @@ export function decideLongPressMove(input: { const dx = input.currentPoint.x - input.startPoint.x; const dy = input.currentPoint.y - input.startPoint.y; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); const distance = Math.sqrt(dx * dx + dy * dy); + const clearlyVertical = absDy >= absDx * directionalDominanceRatio; + const clearlyHorizontal = absDx >= absDy * directionalDominanceRatio; - if (!input.longPressArmed) { + if (!input.dragArmed) { + if (clearlyVertical && absDy > scrollSlopPx) { + return "vertical_scroll"; + } + if (clearlyHorizontal && absDx > swipeSlopPx) { + return "horizontal_swipe"; + } return distance > cancelSlopPx ? "cancel_long_press" : "none"; } return distance > dragSlopPx ? "start_drag" : "none"; @@ -34,4 +55,3 @@ export function shouldOpenContextMenuOnPressOut(input: { }): boolean { return input.longPressArmed && !input.didStartDrag; } - diff --git a/packages/app/src/utils/terminal-attach.test.ts b/packages/app/src/utils/terminal-attach.test.ts index f5c212d73..1c0c13a33 100644 --- a/packages/app/src/utils/terminal-attach.test.ts +++ b/packages/app/src/utils/terminal-attach.test.ts @@ -35,23 +35,29 @@ describe("terminal-attach", () => { it("reads and updates resume offsets monotonically", () => { const offsets = new Map(); const terminalId = "term-1"; + const resumeOffsetStore = { + get: ({ terminalId }: { terminalId: string }) => offsets.get(terminalId), + set: ({ terminalId, offset }: { terminalId: string; offset: number }) => { + offsets.set(terminalId, offset); + }, + }; expect( getTerminalResumeOffset({ terminalId, - resumeOffsetByTerminalId: offsets, + resumeOffsetStore, }) ).toBeUndefined(); updateTerminalResumeOffset({ terminalId, offset: 8, - resumeOffsetByTerminalId: offsets, + resumeOffsetStore, }); expect( getTerminalResumeOffset({ terminalId, - resumeOffsetByTerminalId: offsets, + resumeOffsetStore, }) ).toBe(8); @@ -59,12 +65,12 @@ describe("terminal-attach", () => { updateTerminalResumeOffset({ terminalId, offset: 3, - resumeOffsetByTerminalId: offsets, + resumeOffsetStore, }); expect( getTerminalResumeOffset({ terminalId, - resumeOffsetByTerminalId: offsets, + resumeOffsetStore, }) ).toBe(8); }); diff --git a/packages/app/src/utils/terminal-attach.ts b/packages/app/src/utils/terminal-attach.ts index 969a4c34b..f04586582 100644 --- a/packages/app/src/utils/terminal-attach.ts +++ b/packages/app/src/utils/terminal-attach.ts @@ -8,11 +8,16 @@ const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [ "stream ended", ] as const; +export type TerminalResumeOffsetStore = { + get: (input: { terminalId: string }) => number | undefined; + set: (input: { terminalId: string; offset: number }) => void; +}; + export function getTerminalResumeOffset(input: { terminalId: string; - resumeOffsetByTerminalId: Map; + resumeOffsetStore: TerminalResumeOffsetStore; }): number | undefined { - const offset = input.resumeOffsetByTerminalId.get(input.terminalId); + const offset = input.resumeOffsetStore.get({ terminalId: input.terminalId }); if (typeof offset !== "number" || !Number.isFinite(offset)) { return undefined; } @@ -24,7 +29,7 @@ export function getTerminalResumeOffset(input: { export function updateTerminalResumeOffset(input: { terminalId: string; offset: number; - resumeOffsetByTerminalId: Map; + resumeOffsetStore: TerminalResumeOffsetStore; }): void { if (!Number.isFinite(input.offset)) { return; @@ -34,13 +39,16 @@ export function updateTerminalResumeOffset(input: { const previousOffset = getTerminalResumeOffset({ terminalId: input.terminalId, - resumeOffsetByTerminalId: input.resumeOffsetByTerminalId, + resumeOffsetStore: input.resumeOffsetStore, }) ?? -1; if (normalizedOffset <= previousOffset) { return; } - input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset); + input.resumeOffsetStore.set({ + terminalId: input.terminalId, + offset: normalizedOffset, + }); } export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number { diff --git a/patches/react-native-draggable-flatlist+4.0.3.patch b/patches/react-native-draggable-flatlist+4.0.3.patch index 2adf9a894..d85ed9fc1 100644 --- a/patches/react-native-draggable-flatlist+4.0.3.patch +++ b/patches/react-native-draggable-flatlist+4.0.3.patch @@ -16,3 +16,23 @@ index 7c88afc..b630940 100644 const onScroll = useStableCallback((scrollOffset: number) => { props.onScrollOffsetChange?.(scrollOffset); +diff --git a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx +index 1559352..99d7a9b 100644 +--- a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx +@@ -1,5 +1,5 @@ + import React, { useRef, useState } from "react"; +-import { findNodeHandle, LogBox } from "react-native"; ++import { LogBox } from "react-native"; + import Animated, { + useDerivedValue, + useSharedValue, +@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner( + }); + + const onListContainerLayout = useStableCallback(async ({ containerRef }) => { +- const nodeHandle = findNodeHandle(scrollableRef.current); ++ const nodeHandle = scrollableRef.current; + + const onSuccess = (_x: number, y: number) => { + listVerticalOffset.value = y;