mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic
This commit is contained in:
@@ -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-");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}));
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
103
packages/app/src/terminal/runtime/workspace-terminal-session.ts
Normal file
103
packages/app/src/terminal/runtime/workspace-terminal-session.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user