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:35 +07:00
parent cb24822cac
commit d512932233
22 changed files with 976 additions and 361 deletions
@@ -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
@@ -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>
@@ -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)()
@@ -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>
)
}
+34 -18
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}
+27 -9
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>