Restore workspace header controls and polish tab management

This commit is contained in:
Mohamed Boudra
2026-03-02 12:23:04 +07:00
parent 18d4df339f
commit c8cef9bec8
22 changed files with 1132 additions and 504 deletions

View File

@@ -26,38 +26,87 @@ async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promis
await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), {
timeout: 30000,
});
await expect(page.getByTestId("workspace-new-tab").first()).toBeVisible({
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000,
});
}
test("workspace new-tab menu opens on-screen", async ({ page }) => {
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
try {
await openWorkspaceWithAgent(page, repo.path);
const trigger = page.getByTestId("workspace-new-tab").first();
await expect(trigger).toBeVisible({ timeout: 30000 });
await trigger.click();
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
await expect(agentButton).toBeVisible({ timeout: 30000 });
await expect(terminalButton).toBeVisible({ timeout: 30000 });
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
const menu = page.getByTestId("workspace-new-tab-content").first();
await expect(menu).toBeVisible({ timeout: 10000 });
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
const terminalTabs = page.locator('[data-testid^="workspace-tab-terminal:"]');
const initialTerminalCount = await terminalTabs.count();
const targetTerminalCount = initialTerminalCount + 8;
const menuBounds = await menu.boundingBox();
for (let attempt = initialTerminalCount; attempt < targetTerminalCount; attempt += 1) {
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
await terminalButton.click();
await expect
.poll(async () => await terminalTabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(attempt + 1);
}
const agentBoundsBefore = await agentButton.boundingBox();
const terminalBoundsBefore = await terminalButton.boundingBox();
const viewport = page.viewportSize();
expect(menuBounds).not.toBeNull();
expect(agentBoundsBefore).not.toBeNull();
expect(terminalBoundsBefore).not.toBeNull();
expect(viewport).not.toBeNull();
if (!menuBounds || !viewport) {
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
return;
}
expect(menuBounds.x).toBeGreaterThanOrEqual(0);
expect(menuBounds.y).toBeGreaterThanOrEqual(0);
expect(menuBounds.x + menuBounds.width).toBeLessThanOrEqual(viewport.width);
expect(menuBounds.y + menuBounds.height).toBeLessThanOrEqual(viewport.height);
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
await tabsScroll.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
});
await page.waitForTimeout(200);
const agentBoundsAfter = await agentButton.boundingBox();
const terminalBoundsAfter = await terminalButton.boundingBox();
expect(agentBoundsAfter).not.toBeNull();
expect(terminalBoundsAfter).not.toBeNull();
if (!agentBoundsAfter || !terminalBoundsAfter) {
return;
}
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
} finally {
await repo.cleanup();
}

View File

@@ -2,8 +2,7 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native
import { useState, useEffect, useRef, useCallback } from 'react'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useIsFocused } from '@react-navigation/native'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
@@ -37,6 +36,7 @@ import {
} from '@/attachments/service'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
type QueuedMessage = {
id: string
@@ -84,7 +84,6 @@ export function AgentInputArea({
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
const isScreenFocused = useIsFocused()
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
@@ -551,13 +550,8 @@ export function AgentInputArea({
serverId,
])
const keyboardAnimatedStyle = useAnimatedStyle(() => {
'worklet'
const absoluteHeight = Math.abs(keyboardHeight.value)
const shift = Math.max(0, absoluteHeight - insets.bottom)
return {
transform: [{ translateY: -shift }],
}
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: 'translate',
})
function handleCancelAgent() {

View File

@@ -1,25 +1,41 @@
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@server/shared/agent-lifecycle";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
export function AgentStatusDot({
status,
requiresAttention,
attentionReason,
pendingPermissionCount,
showInactive = false,
}: {
status: string | null | undefined;
requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null;
pendingPermissionCount?: number;
showInactive?: boolean;
}) {
const { theme } = useUnistyles();
const isRunning = status === "running";
const color = isRunning
? theme.colors.palette.blue[500]
: requiresAttention
? theme.colors.success
: showInactive
? theme.colors.border
: null;
if (!status) {
return null;
}
if (!isAgentLifecycleStatus(status)) {
return null;
}
const bucket = deriveSidebarStateBucket({
status,
requiresAttention: Boolean(requiresAttention),
attentionReason: attentionReason ?? null,
pendingPermissionCount: pendingPermissionCount ?? 0,
});
const color = getStatusDotColor({ theme, bucket, showDoneAsInactive: showInactive });
if (!color) {
return null;
@@ -28,6 +44,10 @@ export function AgentStatusDot({
return <View style={[styles.dot, { backgroundColor: color }]} />;
}
function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
return AGENT_LIFECYCLE_STATUSES.some((status) => status === value);
}
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,

View File

@@ -17,6 +17,7 @@ export function DraggableList<T>({
renderItem,
onDragEnd,
style,
containerStyle,
contentContainerStyle,
testID,
ListFooterComponent,
@@ -24,6 +25,8 @@ export function DraggableList<T>({
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
scrollEnabled = true,
useDragHandle: _useDragHandle = false,
refreshing,
onRefresh,
simultaneousGestureRef,
@@ -70,6 +73,8 @@ export function DraggableList<T>({
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
return (
<DraggableFlatList
@@ -79,12 +84,13 @@ export function DraggableList<T>({
renderItem={handleRenderItem}
onDragEnd={handleDragEnd}
style={style}
containerStyle={{ flex: 1 }}
containerStyle={resolvedContainerStyle}
contentContainerStyle={contentContainerStyle}
ListFooterComponent={ListFooterComponent}
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
simultaneousHandlers={simultaneousHandlers}
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
activationDistance={20}

View File

@@ -2,11 +2,22 @@ import type { ReactElement, MutableRefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
export interface DraggableListDragHandleProps {
/**
* Web-only drag handle props (from dnd-kit). Spread these onto the element
* that should initiate the drag. Native uses the `drag()` callback instead.
*/
attributes?: Record<string, unknown>;
listeners?: Record<string, unknown>;
setActivatorNodeRef?: (node: unknown) => void;
}
export interface DraggableRenderItemInfo<T> {
item: T;
index: number;
drag: () => void;
isActive: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
export interface DraggableListProps<T> {
@@ -15,6 +26,8 @@ export interface DraggableListProps<T> {
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd: (data: T[]) => void;
style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
containerStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
testID?: string;
ListFooterComponent?: ReactElement | null;
@@ -22,6 +35,13 @@ export interface DraggableListProps<T> {
ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean;
enableDesktopWebScrollbar?: boolean;
/** When false, disables internal scrolling (use outer list to scroll). */
scrollEnabled?: boolean;
/**
* Web-only: when true, the drag can only be initiated from the handle props
* passed to `renderItem` (prevents nested lists from fighting).
*/
useDragHandle?: boolean;
refreshing?: boolean;
onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */

View File

@@ -41,6 +41,7 @@ interface SortableItemProps<T> {
index: number;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
activeId: string | null;
useDragHandle: boolean;
}
function SortableItem<T>({
@@ -49,11 +50,13 @@ function SortableItem<T>({
index,
renderItem,
activeId,
useDragHandle,
}: SortableItemProps<T>) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
@@ -87,10 +90,23 @@ function SortableItem<T>({
index,
drag,
isActive: activeId === id,
dragHandleProps: useDragHandle
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
}
: undefined,
};
const wrapperProps = useDragHandle
? { ref: setNodeRef }
: { ref: setNodeRef, ...attributes, ...listeners };
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<div {...wrapperProps} style={style}>
{renderItem(info)}
</div>
);
@@ -102,6 +118,7 @@ export function DraggableList<T>({
renderItem,
onDragEnd,
style,
containerStyle,
contentContainerStyle,
testID,
ListFooterComponent,
@@ -109,6 +126,8 @@ export function DraggableList<T>({
ListEmptyComponent,
showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false,
scrollEnabled = true,
useDragHandle = false,
// simultaneousGestureRef is native-only, ignored on web
onDragBegin,
}: DraggableListProps<T>) {
@@ -161,52 +180,90 @@ export function DraggableList<T>({
);
const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar;
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const wrapperStyle = [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
containerStyle,
];
return (
<View style={{ flex: 1, minHeight: 0, position: "relative" }}>
<ScrollView
ref={scrollViewRef}
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
<View style={wrapperStyle}>
{scrollEnabled ? (
<ScrollView
ref={scrollViewRef}
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
}
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
) : (
<>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</>
)}
<WebDesktopScrollbarOverlay
enabled={showCustomScrollbar}
metrics={scrollbarMetrics}

View File

@@ -9,7 +9,6 @@ import Animated, {
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -21,9 +20,9 @@ import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animati
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
@@ -33,16 +32,6 @@ function logExplorerSidebar(event: string, details: Record<string, unknown>): vo
console.log(`[ExplorerSidebar] ${event}`, details);
}
function resolveKeyboardShift(rawHeight: number, inset: number): number {
"worklet";
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
return 0;
}
return Math.max(0, rawHeight - inset);
}
interface ExplorerSidebarProps {
serverId: string;
workspaceId?: string | null;
@@ -69,14 +58,13 @@ export function ExplorerSidebar({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
});
useEffect(() => {
if (isMobile) {
@@ -257,14 +245,6 @@ export function ExplorerSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
return {
paddingBottom: bottomInset.value + shift,
};
});
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
@@ -322,7 +302,9 @@ export function ExplorerSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
<Animated.View
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View

View File

@@ -6,6 +6,7 @@ import Animated, {
interpolate,
Extrapolation,
runOnJS,
useSharedValue,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
@@ -127,6 +128,8 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
} = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false)
@@ -236,17 +239,47 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
)
// Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement
// This mirrors the explorer-sidebar pattern for the right sidebar
const closeGesture = Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isOpen)
// Only activate on leftward swipe (negative X)
.activeOffsetX(-15)
// Fail on rightward movement (allow internal list scrolling)
.failOffsetX(10)
// Fail if vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
// Use manual activation so child views keep touch streams unless we detect
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
// Fail quickly on clear rightward or vertical intent so child views keep control.
if (deltaX >= 10) {
stateManager.fail()
return
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
}
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
}
})
.onStart(() => {
isGesturing.value = true
})

View File

@@ -9,20 +9,21 @@ import {
type ReactElement,
type MutableRefObject,
} from 'react'
import { router, usePathname } from 'expo-router'
import { router, useSegments } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes'
import { buildHostWorkspaceRoute } from '@/utils/host-routes'
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-agents-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { formatTimeAgo } from '@/utils/time'
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
type SidebarTreeRow =
| {
@@ -68,7 +69,6 @@ interface ProjectRowProps {
interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry
compact?: boolean
onPress: () => void
onLongPress: () => void
}
@@ -116,21 +116,23 @@ function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): strin
return formatTimeAgo(workspace.createdAt)
}
function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucket'] }) {
function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['theme']; bucket: SidebarStateBucket }) {
const { theme, bucket } = input
return bucket === 'needs_input'
? theme.colors.palette.amber[500]
: bucket === 'failed'
? theme.colors.palette.red[500]
: bucket === 'running'
? theme.colors.palette.blue[500]
: bucket === 'attention'
? theme.colors.palette.green[500]
: theme.colors.border
}
function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
const { theme } = useUnistyles()
const color =
bucket === 'needs_input'
? theme.colors.palette.amber[500]
: bucket === 'failed'
? theme.colors.palette.red[500]
: bucket === 'running'
? theme.colors.palette.blue[500]
: bucket === 'attention'
? theme.colors.palette.green[500]
: theme.colors.border
return <View style={[styles.projectStatusDot, { backgroundColor: color }]} />
const color = resolveStatusDotColor({ theme, bucket })
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
}
function ProjectRow({
@@ -185,19 +187,15 @@ function ProjectRow({
</View>
)}
<ProjectStatusDot bucket={project.statusBucket} />
<Text style={styles.projectTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Text style={styles.projectCountText}>{project.workspaces.length}</Text>
</Pressable>
)
}
function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: WorkspaceRowProps) {
function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
const didLongPressRef = useRef(false)
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
@@ -218,7 +216,6 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
<Pressable
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
compact && styles.workspaceRowCompact,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
@@ -227,9 +224,12 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)}
</Text>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusDot bucket={workspace.statusBucket} />
<Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)}
</Text>
</View>
{createdAtLabel ? (
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{createdAtLabel}
@@ -273,7 +273,8 @@ export function SidebarAgentList({
}: SidebarAgentListProps) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
const pathname = usePathname()
const segments = useSegments()
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
@@ -419,13 +420,11 @@ export function SidebarAgentList({
}
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname))
const navigate = shouldReplace ? router.replace : router.push
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
return (
<WorkspaceRow
workspace={item.workspace}
compact={isMobile}
onPress={() => {
if (!serverId) {
return
@@ -441,9 +440,9 @@ export function SidebarAgentList({
collapsedProjectKeys,
isMobile,
onWorkspacePress,
pathname,
projectIconByProjectKey,
serverId,
shouldReplaceWorkspaceNavigation,
toggleProjectCollapsed,
]
)
@@ -603,27 +602,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: 9,
},
projectStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flex: 1,
minWidth: 0,
},
projectCountText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
workspaceRow: {
minHeight: 34,
minHeight: 36,
marginBottom: theme.spacing[1],
marginLeft: theme.spacing[4],
paddingVertical: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
@@ -631,9 +619,12 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: 'space-between',
gap: theme.spacing[2],
},
workspaceRowCompact: {
marginLeft: theme.spacing[3],
paddingHorizontal: theme.spacing[1],
workspaceRowLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
workspaceRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -641,9 +632,15 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
},
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.sm,
flex: 1,
minWidth: 0,
},

View File

@@ -9,6 +9,7 @@ import {
View,
} from "react-native";
import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
@@ -19,6 +20,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
@@ -145,6 +147,10 @@ export function TerminalPane({
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
});
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(serverId);
@@ -185,6 +191,7 @@ export function TerminalPane({
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const updateSelectedTerminalId = useCallback(
(
@@ -298,6 +305,41 @@ export function TerminalPane({
setResizeRequestToken((current) => current + 1);
}, []);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
return;
}
for (const handle of keyboardRefitTimeoutsRef.current) {
clearTimeout(handle);
}
keyboardRefitTimeoutsRef.current = [];
}, []);
const pulseKeyboardRefits = useCallback(() => {
clearKeyboardRefitTimeouts();
requestTerminalReflow();
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
useEffect(() => {
return () => clearKeyboardRefitTimeouts();
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => keyboardShift.value > 0,
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
},
[pulseKeyboardRefits]
);
useFocusEffect(
useCallback(() => {
if (!selectedTerminalId) {
@@ -353,16 +395,8 @@ export function TerminalPane({
streamId: message.payload.streamId,
});
setModifiers({ ...EMPTY_MODIFIERS });
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: terminalsQueryKey,
type: "active",
});
});
}, [client, isConnected, queryClient, terminalsQueryKey]);
}, [client, isConnected]);
useEffect(() => {
if (
@@ -381,13 +415,12 @@ export function TerminalPane({
if (message.payload.cwd !== cwd) {
return;
}
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: terminalsQueryKey,
type: "active",
});
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd });
@@ -960,7 +993,7 @@ export function TerminalPane({
const combinedError = streamError ?? closeError ?? createError ?? queryError;
return (
<View style={styles.container}>
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
{!hideHeader ? (
<View style={styles.header} testID="terminals-header">
<ScrollView
@@ -1161,7 +1194,7 @@ export function TerminalPane({
</ScrollView>
</View>
) : null}
</View>
</Animated.View>
);
}

View File

@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
function resolveKeyboardShift(input: {
rawKeyboardHeight: number;
bottomInset: number;
isIos: boolean;
iosMinHeight: number;
enabled: boolean;
}): number {
"worklet";
if (!input.enabled) {
return 0;
}
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
return 0;
}
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
type KeyboardShiftMode = "translate" | "padding";
export function useKeyboardShiftStyle(input: {
mode: KeyboardShiftMode;
enabled?: boolean;
iosMinHeight?: number;
}): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
} {
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const enabled = input.enabled ?? true;
const isIos = Platform.OS === "ios";
const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT;
useEffect(() => {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
const shift = useDerivedValue(() => {
"worklet";
const rawKeyboardHeight = Math.abs(keyboardHeight.value);
return resolveKeyboardShift({
rawKeyboardHeight,
bottomInset: bottomInset.value,
isIos,
iosMinHeight,
enabled,
});
});
const style = useAnimatedStyle<ViewStyle>(() => {
"worklet";
if (input.mode === "padding") {
if (!enabled) {
return { paddingBottom: 0 };
}
// Include safe-area bottom inset so content clears the home indicator even without a keyboard.
return { paddingBottom: bottomInset.value + shift.value };
}
return { transform: [{ translateY: -shift.value }] };
}, [input.mode]);
return { shift, style };
}

View File

@@ -25,6 +25,7 @@ export interface SidebarWorkspaceEntry {
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
statusBucket: SidebarStateBucket
}
export interface SidebarProjectEntry {
@@ -54,6 +55,7 @@ interface MutableWorkspaceEntry {
createdAt: Date | null
isMainCheckout: boolean
isPaseoOwnedWorktree: boolean
statusBucket: SidebarStateBucket
}
interface MutableProjectEntry {
@@ -256,6 +258,7 @@ function ensureWorkspace(
createdAt: input.createdAt,
isMainCheckout: input.isMainCheckout,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree,
statusBucket: 'done',
}
project.workspacesByKey.set(workspaceKey, workspace)
return workspace
@@ -394,6 +397,7 @@ export function useSidebarAgentsList(options?: {
isMainCheckout,
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
})
workspace.statusBucket = aggregateBucket(workspace.statusBucket, bucket)
const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot)
if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) {
@@ -573,6 +577,7 @@ export function useSidebarAgentsList(options?: {
createdAt: workspace.createdAt,
isMainCheckout: workspace.isMainCheckout,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
statusBucket: workspace.statusBucket,
})
)

View File

@@ -12,12 +12,8 @@ import { useRouter } from "expo-router";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import ReanimatedAnimated from "react-native-reanimated";
import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
@@ -78,6 +74,7 @@ import {
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -508,20 +505,8 @@ function AgentScreenContent({
[serverId]
);
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const handleHistorySyncFailure = useCallback(

View File

@@ -6,9 +6,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { useIsFocused } from '@react-navigation/native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import { GestureDetector } from 'react-native-gesture-handler'
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import { Folder, GitBranch, PanelRight } from 'lucide-react-native'
import { SidebarMenuToggle } from '@/components/headers/menu-header'
import { HeaderToggleButton } from '@/components/headers/header-toggle-button'
@@ -51,6 +50,7 @@ import type {
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostAgentDetailRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
const DRAFT_AGENT_ID = '__new_agent__'
const EMPTY_PENDING_PERMISSIONS = new Map()
@@ -148,20 +148,8 @@ function DraftAgentScreenContent({
)
const params = useLocalSearchParams<DraftAgentParams>()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
const bottomInset = useSharedValue(insets.bottom)
useEffect(() => {
bottomInset.value = insets.bottom
}, [insets.bottom, bottomInset])
const animatedKeyboardStyle = useAnimatedStyle(() => {
'worklet'
const absoluteHeight = Math.abs(keyboardHeight.value)
const shift = Math.max(0, absoluteHeight - bottomInset.value)
return {
transform: [{ translateY: -shift }],
}
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: 'translate',
})
const forcedServerIdParam = forcedServerId?.trim()

File diff suppressed because it is too large Load Diff

View File

@@ -34,4 +34,15 @@ describe("deriveSidebarStateBucket", () => {
})
).toBe("attention");
});
it("treats initializing agents as running", () => {
expect(
deriveSidebarStateBucket({
status: "initializing",
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
})
).toBe("running");
});
});

View File

@@ -31,7 +31,7 @@ export function deriveSidebarStateBucket(input: {
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
if (input.status === "running") {
if (input.status === "running" || input.status === "initializing") {
return "running";
}
if (input.requiresAttention) {

View File

@@ -0,0 +1,27 @@
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
export function getStatusDotColor(input: {
theme: Theme;
bucket: SidebarStateBucket;
showDoneAsInactive?: boolean;
}): string | null {
const { theme, bucket, showDoneAsInactive = false } = input;
if (bucket === "needs_input") {
return theme.colors.palette.amber[500];
}
if (bucket === "failed") {
return theme.colors.palette.red[500];
}
if (bucket === "running") {
return theme.colors.palette.blue[500];
}
if (bucket === "attention") {
return theme.colors.palette.green[500];
}
if (bucket === "done") {
return showDoneAsInactive ? theme.colors.border : null;
}
return null;
}

View File

@@ -0,0 +1 @@
cid_48610cdfae94492497dcf8d77214267c

View File

@@ -383,8 +383,7 @@ const shouldRun = !process.env.CI;
await waitForCondition(() => sawExit, 10000);
const next = await ctx.client.listTerminals(cwd);
expect(next.terminals).toHaveLength(1);
expect(next.terminals[0].id).not.toBe(terminalId);
expect(next.terminals).toHaveLength(0);
unsubscribeExit();
rmSync(cwd, { recursive: true, force: true });

View File

@@ -200,13 +200,15 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(id)).toBeUndefined();
});
it("removes cwd entry when last terminal is killed", async () => {
it("keeps cwd entry when last terminal is killed (but does not auto-recreate)", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).not.toContain("/tmp");
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
});
it("keeps cwd entry when other terminals remain", async () => {
@@ -239,8 +241,7 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(1);
expect(remaining[0].id).not.toBe(exitedId);
expect(remaining).toHaveLength(0);
});
});
@@ -250,7 +251,7 @@ describe("TerminalManager", () => {
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds with active terminals", async () => {
it("returns all cwds that have ever had terminals", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
await manager.getTerminals("/home");

View File

@@ -35,6 +35,7 @@ export function createTerminalManager(): TerminalManager {
const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
const knownDirectories = new Set<string>();
function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) {
@@ -134,8 +135,12 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd);
let terminals = terminalsByCwd.get(cwd);
if (!terminals || terminals.length === 0) {
const terminals = terminalsByCwd.get(cwd);
if (terminals && terminals.length > 0) {
return terminals;
}
if (!knownDirectories.has(cwd)) {
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
const session = registerSession(
await createTerminal({
@@ -144,11 +149,14 @@ export function createTerminalManager(): TerminalManager {
...(inheritedEnv ? { env: inheritedEnv } : {}),
})
);
terminals = [session];
terminalsByCwd.set(cwd, terminals);
const created = [session];
terminalsByCwd.set(cwd, created);
knownDirectories.add(cwd);
emitTerminalsChanged({ cwd });
return created;
}
return terminals;
return [];
},
async createTerminal(options: {
@@ -158,6 +166,7 @@ export function createTerminalManager(): TerminalManager {
}): Promise<TerminalSession> {
assertAbsolutePath(options.cwd);
knownDirectories.add(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? [];
const defaultName = `Terminal ${terminals.length + 1}`;
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
@@ -194,13 +203,14 @@ export function createTerminalManager(): TerminalManager {
},
listDirectories(): string[] {
return Array.from(terminalsByCwd.keys());
return Array.from(knownDirectories);
},
killAll(): void {
for (const id of Array.from(terminalsById.keys())) {
removeSessionById(id, { kill: true });
}
knownDirectories.clear();
},
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {