refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic

This commit is contained in:
Mohamed Boudra
2026-03-06 22:23:03 +07:00
parent cb24822cac
commit d512932233
22 changed files with 976 additions and 361 deletions

View File

@@ -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<void> {
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<string> {
};
}).__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-");

View File

@@ -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 = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
<View style={{ flex: 1 }}>
{children}
</View>
</View>

View File

@@ -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<T>({
simultaneousGestureRef,
waitFor,
onDragBegin: onDragBeginProp,
onDragIntent: onDragIntentProp,
onDragRelease: onDragReleaseProp,
nestable: _nestable = false,
nestable = false,
}: DraggableListProps<T>) {
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<T>) => {
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<T> = {
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 (
<DraggableFlatList
<ListComponent
testID={testID}
data={data}
keyExtractor={keyExtractor}
@@ -101,7 +97,9 @@ export function DraggableList<T>({
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

View File

@@ -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 (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View style={styles.row} {...dragHandlers}>
<View
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
{...dragHandlers}
>
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
</View>

View File

@@ -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<string, unknown>): 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)()

View File

@@ -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 = (
<ContextMenuTrigger
enabledOnMobile={false}
<Pressable
style={({ pressed, hovered = false }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
@@ -407,12 +445,6 @@ function ProjectHeaderRow({
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.projectRowLeft}
>
{collapsed ? (
<ChevronRight size={14} color="#9ca3af" />
) : (
<ChevronDown size={14} color="#9ca3af" />
)}
{iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
@@ -424,29 +456,19 @@ function ProjectHeaderRow({
<Text style={styles.projectTitle} numberOfLines={1}>
{displayName}
</Text>
{collapsed ? (
<ChevronRight size={14} color="#9ca3af" />
) : (
<ChevronDown size={14} color="#9ca3af" />
)}
</View>
</ContextMenuTrigger>
</Pressable>
)
return trigger
}
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
return (
<ContextMenu>
<ProjectHeaderRow {...props} />
<ContextMenuContent align="start" width={220} testID={`sidebar-project-context-${props.project.projectKey}`}>
<ContextMenuItem
testID={`sidebar-project-context-${props.project.projectKey}-toggle`}
onSelect={props.onToggle}
>
{props.collapsed ? 'Expand project' : 'Collapse project'}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
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 (
<ContextMenu>
<WorkspaceRowWithMenuContent
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
</ContextMenu>
)
}
function WorkspaceRowPlain({
workspace,
selected,
@@ -714,18 +771,16 @@ function WorkspaceRow({
}
return (
<ContextMenu>
<WorkspaceRowWithMenu
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
</ContextMenu>
<WorkspaceRowWithMenu
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
)
}
@@ -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 (
<View style={styles.projectBlock}>
<ProjectHeaderRowWithMenu
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
@@ -828,13 +879,7 @@ function ProjectBlock({
data={project.workspaces}
keyExtractor={(workspace) => 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<Set<string>>(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 (
<View style={styles.container}>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
scrollEnabled={outerScrollEnabled}
onScrollBeginDrag={() =>
console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled })
}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</ScrollView>
{isNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
onScrollBeginDrag={() =>
console.log('[sidebar-dnd-debug] outer scroll begin')
}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
onScrollBeginDrag={() =>
console.log('[sidebar-dnd-debug] outer scroll begin')
}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</ScrollView>
)}
</View>
)
}

View File

@@ -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<TerminalStreamController | null>(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}

View File

@@ -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) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
kind={opt.kind}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
/>
renderOption ? (
<View key={opt.id}>
{renderOption({
option: opt,
selected: opt.id === value,
active: index === activeIndex,
onPress: () => handleSelect(opt.id),
})}
</View>
) : (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
kind={opt.kind}
selected={opt.id === value}
active={index === activeIndex}
onPress={() => handleSelect(opt.id)}
/>
)
))
) : (
<ComboboxEmpty>{emptyText}</ComboboxEmpty>

View File

@@ -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" ? (
<View style={styles.tabAgentIconWrapper}>
{tab.provider === "claude" ? (
<ClaudeIcon size={14} color={iconColor} />
) : tab.provider === "codex" ? (
<CodexIcon size={14} color={iconColor} />
) : (
<Bot size={14} color={iconColor} />
)}
{tabAgentStatusColor ? (
<View
style={[
styles.tabStatusDot,
{
backgroundColor: tabAgentStatusColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
</View>
) : tab.kind === "draft" ? (
<Pencil size={14} color={iconColor} />
) : tab.kind === "file" ? (
<FileText size={14} color={iconColor} />
) : (
<Terminal size={14} color={iconColor} />
);
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}
>
<View style={styles.tabIcon}>{icon}</View>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isActive} />
</View>
{showLabel ? (
tab.kind === "agent" && tab.titleState === "loading" ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
@@ -453,22 +410,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,
},

View File

@@ -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<string, ReturnType<typeof deriveWorkspaceTabPresentation>>();
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({
>
<View style={styles.switcherTriggerLeft}>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
{(() => {
const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null;
if (!activeDescriptor) {
return <View style={styles.tabIcon}><Bot size={14} color={theme.colors.foregroundMuted} /></View>;
}
if (activeDescriptor.kind === "terminal") {
return <Terminal size={14} color={theme.colors.foreground} />;
}
if (activeDescriptor.kind === "file") {
return <FileText size={14} color={theme.colors.foreground} />;
}
if (activeDescriptor.kind === "draft") {
return <Pencil size={14} color={theme.colors.foreground} />;
}
if (activeDescriptor.kind !== "agent") {
return <Bot size={14} color={theme.colors.foreground} />;
}
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 (
<View style={styles.tabAgentIconWrapper}>
{activeDescriptor.provider === "claude" ? (
<ClaudeIcon size={14} color={theme.colors.foreground} />
) : activeDescriptor.provider === "codex" ? (
<CodexIcon size={14} color={theme.colors.foreground} />
) : (
<Bot size={14} color={theme.colors.foreground} />
)}
{tabAgentStatusColor ? (
<View
style={[
styles.tabStatusDot,
{
backgroundColor: tabAgentStatusColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
</View>
);
})()}
{activeTabPresentation ? (
<WorkspaceTabIcon presentation={activeTabPresentation} active />
) : null}
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
@@ -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 <View />;
}
const presentation =
tabPresentationsByKey.get(option.id) ??
deriveWorkspaceTabPresentation({ tab });
return (
<WorkspaceTabOptionRow
presentation={presentation}
selected={selected}
active={active}
onPress={onPress}
/>
);
}}
/>
</View>
) : (
@@ -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,
},

View File

@@ -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 (
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
{presentation.provider === "claude" ? (
<ClaudeIcon size={size} color={iconColor} />
) : presentation.provider === "codex" ? (
<CodexIcon size={size} color={iconColor} />
) : (
<Bot size={size} color={iconColor} />
)}
{statusDotColor ? (
<View
style={[
styles.statusDot,
{
backgroundColor: statusDotColor,
borderColor: statusDotBorderColor ?? theme.colors.surface0,
},
]}
/>
) : null}
</View>
);
}
if (presentation.kind === "draft") {
return <Pencil size={size} color={iconColor} />;
}
if (presentation.kind === "file") {
return <FileText size={size} color={iconColor} />;
}
return <Terminal size={size} color={iconColor} />;
}
type WorkspaceTabOptionRowProps = {
presentation: WorkspaceTabPresentation;
selected: boolean;
active: boolean;
onPress: () => void;
};
export function WorkspaceTabOptionRow({
presentation,
selected,
active,
onPress,
}: WorkspaceTabOptionRowProps): ReactElement {
const { theme } = useUnistyles();
return (
<Pressable
onPress={onPress}
style={({ hovered = false, pressed }) => [
styles.optionRow,
(hovered || pressed || active) && styles.optionRowActive,
]}
>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
</Text>
</View>
{selected ? (
<View style={styles.optionTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</Pressable>
);
}
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",
},
}));

View File

@@ -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]);
}

View File

@@ -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) {

View File

@@ -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<string, number>();
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,
});
});
});

View File

@@ -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<string, number>();
private readonly exitedStreamsWhileAttaching = new Set<number>();
private readonly internalResumeOffsetByTerminalId = new Map<string, number>();
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);
},
};
}
}

View File

@@ -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();
});
});

View File

@@ -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<string, number>;
session: WorkspaceTerminalSession;
};
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createResumeOffsets(input: {
resumeOffsetByTerminalId: Map<string, number>;
}): 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<string, number>();
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);
}

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -35,23 +35,29 @@ describe("terminal-attach", () => {
it("reads and updates resume offsets monotonically", () => {
const offsets = new Map<string, number>();
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);
});

View File

@@ -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<string, number>;
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<string, number>;
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 {

View File

@@ -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<T>(
});
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
- const nodeHandle = findNodeHandle(scrollableRef.current);
+ const nodeHandle = scrollableRef.current;
const onSuccess = (_x: number, y: number) => {
listVerticalOffset.value = y;