feat: sortable workspace tabs, tab context menus, and terminal auto-create removal

- Add drag-and-drop tab reordering with persistent tab order
- Add right-click context menu with copy agent ID, copy resume command, close to right
- Switch workspace route encoding from percent-encoding to base64url
- Remove terminal auto-creation on directory listing (explicit create only)
- Add swipe gestures on terminal emulator for mobile sidebar navigation
- Highlight active workspace row in sidebar
This commit is contained in:
Mohamed Boudra
2026-03-02 15:59:13 +07:00
parent c8cef9bec8
commit 223c8d7d0b
19 changed files with 1971 additions and 285 deletions

2
.gitignore vendored
View File

@@ -74,3 +74,5 @@ valknut-report.json/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
/artifacts

View File

@@ -6,9 +6,10 @@ import {
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
function buildWorkspaceRoute(serverId: string, workspacePath: string): string {
return `/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent(workspacePath)}`;
return buildHostWorkspaceRoute(serverId, workspacePath);
}
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {

View File

@@ -9,14 +9,19 @@ import {
type ReactElement,
type MutableRefObject,
} from 'react'
import { router, useSegments } from 'expo-router'
import { router, usePathname, 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 } from '@/utils/host-routes'
import {
buildHostWorkspaceRoute,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
parseHostWorkspaceTerminalRouteFromPathname,
} from '@/utils/host-routes'
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -69,6 +74,7 @@ interface ProjectRowProps {
interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry
selected: boolean
onPress: () => void
onLongPress: () => void
}
@@ -195,7 +201,7 @@ function ProjectRow({
)
}
function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) {
const didLongPressRef = useRef(false)
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
@@ -216,6 +222,7 @@ function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
<Pressable
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
@@ -274,6 +281,7 @@ export function SidebarAgentList({
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
const segments = useSegments()
const pathname = usePathname()
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
@@ -283,6 +291,23 @@ export function SidebarAgentList({
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder)
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder)
const activeWorkspaceSelection = useMemo(() => {
if (!pathname) {
return null
}
const parsed =
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
parseHostWorkspaceTerminalRouteFromPathname(pathname) ??
parseHostWorkspaceRouteFromPathname(pathname)
if (!parsed) {
return null
}
return {
serverId: parsed.serverId,
workspaceId: parsed.workspaceId,
}
}, [pathname])
useEffect(() => {
setCollapsedProjectKeys((prev) => {
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
@@ -421,10 +446,15 @@ export function SidebarAgentList({
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
const isSelected =
Boolean(serverId) &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === item.workspace.cwd
return (
<WorkspaceRow
workspace={item.workspace}
selected={isSelected}
onPress={() => {
if (!serverId) {
return
@@ -437,6 +467,7 @@ export function SidebarAgentList({
)
},
[
activeWorkspaceSelection,
collapsedProjectKeys,
isMobile,
onWorkspacePress,
@@ -632,6 +663,9 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowSelected: {
backgroundColor: theme.colors.surface2,
},
workspaceStatusDot: {
width: 8,
height: 8,

View File

@@ -0,0 +1,44 @@
import type { ReactElement } from "react";
import type { DraggableRenderItemInfo } from "./draggable-list.types";
export function SortableInlineList<T>({
data,
keyExtractor,
renderItem,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd?: (data: T[]) => void;
useDragHandle?: boolean;
disabled?: boolean;
}): ReactElement {
return (
<>
{data.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableInlineListItem key={id} item={item} index={index} renderItem={renderItem} />
);
})}
</>
);
}
function SortableInlineListItem<T>({
item,
index,
renderItem,
}: {
item: T;
index: number;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
}): ReactElement {
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag: () => {},
isActive: false,
};
return renderItem(info);
}

View File

@@ -0,0 +1,8 @@
// This file exists for TypeScript resolution.
// The actual implementations are in:
// - sortable-inline-list.native.tsx (iOS/Android)
// - sortable-inline-list.web.tsx (Web)
// Metro's platform-specific extensions will pick the right one at runtime.
export * from "./sortable-inline-list.native";

View File

@@ -0,0 +1,206 @@
import { useCallback, useEffect, useState, type ReactElement } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
type Modifier,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableRenderItemInfo } from "./draggable-list.types";
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
y: 0,
});
function SortableItem<T>({
id,
item,
index,
renderItem,
activeId,
useDragHandle,
disabled,
}: {
id: string;
item: T;
index: number;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
activeId: string | null;
useDragHandle: boolean;
disabled: boolean;
}): ReactElement {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
// This is a no-op but matches the mobile API
}, []);
const baseTransform = CSS.Transform.toString(transform);
const scaleTransform = isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
const style = {
transform: combinedTransform || undefined,
transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1,
};
const info: DraggableRenderItemInfo<T> = {
item,
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 {...wrapperProps} style={style}>
{renderItem(info)}
</div>
);
}
export function SortableInlineList<T>({
data,
keyExtractor,
renderItem,
onDragEnd,
useDragHandle = false,
disabled = false,
activationDistance = 8,
onDragBegin,
}: {
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd?: (data: T[]) => void;
useDragHandle?: boolean;
disabled?: boolean;
activationDistance?: number;
onDragBegin?: () => void;
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState<T[]>(() => data);
useEffect(() => {
if (activeId) {
return;
}
setItems((current) => (current === data ? current : data));
}, [activeId, data]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: activationDistance,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (disabled) {
return;
}
setActiveId(String(event.active.id));
onDragBegin?.();
},
[disabled, onDragBegin]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (disabled) {
return;
}
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
onDragEnd?.(newItems);
}
}
},
[disabled, items, keyExtractor, onDragEnd]
);
const ids = items.map((item, index) => keyExtractor(item, index));
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{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}
disabled={disabled}
/>
);
})}
</SortableContext>
</DndContext>
);
}

View File

@@ -16,6 +16,9 @@ interface TerminalEmulatorProps {
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
@@ -45,6 +48,9 @@ export default function TerminalEmulator({
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
onInput,
onResize,
onTerminalKey,
@@ -59,6 +65,122 @@ export default function TerminalEmulator({
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedInitialOutputRef = useRef<string | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
return;
}
const SWIPE_MIN_PX = 22;
const VERTICAL_CANCEL_PX = 12;
const HORIZONTAL_DOMINANCE_RATIO = 1.2;
let tracking = false;
let activePointerId: number | null = null;
let startX = 0;
let startY = 0;
let fired = false;
const reset = () => {
tracking = false;
activePointerId = null;
startX = 0;
startY = 0;
fired = false;
};
const shouldTreatAsVertical = (dx: number, dy: number) => {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy < VERTICAL_CANCEL_PX) {
return false;
}
return absDy > absDx;
};
const shouldTreatAsHorizontal = (dx: number, dy: number) => {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < SWIPE_MIN_PX) {
return false;
}
if (absDy === 0) {
return true;
}
return absDx / absDy >= HORIZONTAL_DOMINANCE_RATIO;
};
const onPointerDown = (event: PointerEvent) => {
if (!event.isPrimary) {
return;
}
tracking = true;
fired = false;
activePointerId = event.pointerId;
startX = event.clientX;
startY = event.clientY;
};
const onPointerMove = (event: PointerEvent) => {
if (!tracking || fired) {
return;
}
if (activePointerId !== null && event.pointerId !== activePointerId) {
return;
}
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (shouldTreatAsVertical(dx, dy)) {
reset();
return;
}
if (!shouldTreatAsHorizontal(dx, dy)) {
return;
}
fired = true;
if (dx > 0) {
onSwipeRight?.();
} else {
onSwipeLeft?.();
}
if (event.cancelable) {
event.preventDefault();
}
};
const onPointerUp = (event: PointerEvent) => {
if (activePointerId !== null && event.pointerId !== activePointerId) {
return;
}
reset();
};
const onPointerCancel = (event: PointerEvent) => {
if (activePointerId !== null && event.pointerId !== activePointerId) {
return;
}
reset();
};
root.addEventListener("pointerdown", onPointerDown, { passive: true });
root.addEventListener("pointermove", onPointerMove, { passive: false });
root.addEventListener("pointerup", onPointerUp, { passive: true });
root.addEventListener("pointercancel", onPointerCancel, { passive: true });
return () => {
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerup", onPointerUp);
root.removeEventListener("pointercancel", onPointerCancel);
};
}, [onSwipeLeft, onSwipeRight, swipeGesturesEnabled]);
useEffect(() => {
const host = hostRef.current;
const root = rootRef.current;
@@ -185,6 +307,7 @@ export default function TerminalEmulator({
backgroundColor,
overflow: "hidden",
overscrollBehavior: "none",
touchAction: "pan-y",
}}
onPointerDown={() => {
runtimeRef.current?.focus();

View File

@@ -41,6 +41,7 @@ import {
summarizeTerminalText,
terminalDebugLog,
} from "@/terminal/runtime/terminal-debug";
import { usePanelStore } from "@/stores/panel-store";
import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
@@ -147,6 +148,10 @@ export function TerminalPane({
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
@@ -1121,6 +1126,19 @@ export function TerminalPane({
backgroundColor={theme.colors.background}
foregroundColor={theme.colors.foreground}
cursorColor={theme.colors.foreground}
swipeGesturesEnabled={swipeGesturesEnabled}
onSwipeRight={() => {
if (!swipeGesturesEnabled) {
return;
}
openAgentList();
}}
onSwipeLeft={() => {
if (!swipeGesturesEnabled) {
return;
}
openFileExplorer();
}}
onInput={handleTerminalData}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}

View File

@@ -0,0 +1,707 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import {
ActivityIndicator,
Dimensions,
Modal,
Platform,
Pressable,
ScrollView,
StatusBar,
Text,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
type Placement = "top" | "bottom" | "left" | "right";
type Alignment = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type ContextMenuContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
};
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
function useContextMenuContext(componentName: string): ContextMenuContextValue {
const ctx = useContext(ContextMenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <ContextMenu />`);
}
return ctx;
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
placement,
alignment,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
}): { x: number; y: number; actualPlacement: Placement } {
const { width: contentWidth, height: contentHeight } = contentSize;
// Calculate available space
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
// Flip if needed
let actualPlacement = placement;
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualPlacement = "top";
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualPlacement = "bottom";
}
let x: number;
let y: number;
// Position based on placement
if (actualPlacement === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
} else if (actualPlacement === "top") {
y = triggerRect.y - contentHeight - offset;
} else if (actualPlacement === "left") {
x = triggerRect.x - contentWidth - offset;
y = triggerRect.y;
} else {
x = triggerRect.x + triggerRect.width + offset;
y = triggerRect.y;
}
// Alignment
if (actualPlacement === "top" || actualPlacement === "bottom") {
if (alignment === "start") {
x = triggerRect.x;
} else if (alignment === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!)
);
return { x, y, actualPlacement };
}
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
const nativeEvent: any = (event as any)?.nativeEvent ?? event;
const pageX = nativeEvent?.pageX;
const pageY = nativeEvent?.pageY;
if (typeof pageX === "number" && typeof pageY === "number") {
return { pageX, pageY };
}
const clientX = nativeEvent?.clientX;
const clientY = nativeEvent?.clientY;
if (typeof clientX === "number" && typeof clientY === "number") {
return { pageX: clientX, pageY: clientY };
}
return null;
}
export function ContextMenu({
open,
defaultOpen,
onOpenChange,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
useEffect(() => {
if (!isOpen) {
setAnchorRect(null);
}
}, [isOpen]);
const value = useMemo<ContextMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
anchorRect,
setAnchorRect,
}),
[anchorRect, isOpen, setAnchorRect, setIsOpen]
);
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
}
type TriggerState = { pressed: boolean; hovered: boolean; open: boolean };
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
export function ContextMenuTrigger({
children,
disabled,
style,
enabled = true,
enabledOnMobile = false,
enabledOnWeb = true,
longPressDelayMs,
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
style?: TriggerStyleProp;
enabled?: boolean;
enabledOnMobile?: boolean;
enabledOnWeb?: boolean;
longPressDelayMs?: number;
}
>): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform =
enabled &&
(Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
if (!shouldEnableOnThisPlatform || disabled) {
return;
}
const point = coerceEventPoint(event);
if (!point) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
ctx.setAnchorRect({
x: point.pageX,
y: point.pageY + statusBarHeight,
width: 0,
height: 0,
});
ctx.setOpen(true);
},
[ctx, disabled, shouldEnableOnThisPlatform]
);
return (
<Pressable
{...props}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={(event) => {
if (Platform.OS === "web") {
props.onLongPress?.(event);
return;
}
openAtEvent(event);
props.onLongPress?.(event);
}}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => {
if (Platform.OS !== "web") {
return;
}
const e: any = event;
e?.preventDefault?.();
e?.stopPropagation?.();
openAtEvent(event);
}}
style={({ pressed, hovered = false }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
}}
>
{children}
</Pressable>
);
}
export function ContextMenuContent({
children,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
fullWidth = false,
horizontalPadding = 16,
testID,
}: PropsWithChildren<{
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
fullWidth?: boolean;
horizontalPadding?: number;
testID?: string;
}>): ReactElement | null {
const { open, setOpen, triggerRef, anchorRect } = useContextMenuContext("ContextMenuContent");
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
// Measure trigger when opening (fallback) and capture point anchors.
useEffect(() => {
if (!open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
}
if (anchorRect) {
setTriggerRect(anchorRect);
return;
}
if (!triggerRef.current) {
setTriggerRect(null);
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({
...rect,
y: rect.y + statusBarHeight,
});
});
return () => {
cancelled = true;
};
}, [anchorRect, open, triggerRef]);
// Calculate position when we have both measurements
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = {
x: 0,
y: 0,
width: screenWidth,
height: screenHeight,
};
const result = computePosition({
triggerRect,
contentSize,
displayArea,
placement: side,
alignment: align,
offset,
});
const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y });
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[]
);
if (!open) return null;
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return (
<Modal
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
export function ContextMenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
return (
<View style={[styles.labelContainer, style]} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function ContextMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
export function ContextMenuHint({
children,
testID,
}: PropsWithChildren<{ testID?: string }>): ReactElement {
return (
<View style={styles.hintContainer} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
export function ContextMenuItem({
children,
description,
onSelect,
disabled,
destructive,
selected,
showSelectedCheck = false,
selectedVariant = "default",
leading,
trailing,
loading,
status,
pendingLabel,
successLabel,
closeOnSelect = true,
testID,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
disabled?: boolean;
destructive?: boolean;
selected?: boolean;
showSelectedCheck?: boolean;
selectedVariant?: "default" | "accent";
leading?: ReactElement | null;
trailing?: ReactElement | null;
/** @deprecated Use `status` instead */
loading?: boolean;
status?: ActionStatus;
pendingLabel?: string;
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
const isPending = status === "pending" || loading;
const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess;
let leadingContent: ReactElement | null = null;
if (isPending) {
leadingContent = <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
} else if (isSuccess) {
leadingContent = <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
} else if (leading) {
leadingContent = leading;
}
let label = children;
if (isPending && pendingLabel) {
label = pendingLabel;
} else if (isSuccess && successLabel) {
label = successLabel;
}
const trailingContent =
trailing ?? (!showSelectedCheck && selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null);
return (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}}
style={({ pressed, hovered }) => [
styles.item,
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
]}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text
numberOfLines={1}
style={[
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
]}
>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
]}
>
{description}
</Text>
) : null}
</View>
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
overflow: "hidden",
},
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
},
hintContainer: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hintText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
item: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface1,
},
itemPressed: {
backgroundColor: theme.colors.surface1,
},
itemDisabled: {
opacity: 0.5,
},
itemSelected: {
backgroundColor: theme.colors.surface1,
},
itemSelectedInteractive: {
backgroundColor: theme.colors.surface1,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
},
checkSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
leadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
trailingSlot: {
marginLeft: "auto",
alignItems: "center",
justifyContent: "center",
},
itemContent: {
flexShrink: 1,
},
itemText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSuccess: {
color: theme.colors.palette.green[500],
},
itemTextSelectedAccent: {
color: theme.colors.accentForeground,
},
itemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
itemDescriptionSelectedAccent: {
color: theme.colors.accentForeground,
opacity: 0.85,
},
}));

View File

@@ -393,7 +393,10 @@ export function useSidebarAgentsList(options?: {
serverId,
cwd: checkoutCwd,
branchName: normalizedBranch,
createdAt: isMainCheckout ? sourceAgent.createdAt : null,
// Only Paseo-managed worktrees are ephemeral enough to justify showing a timestamp.
// If the daemon doesn't provide worktree.createdAt (older daemons), fall back to an
// agent-derived timestamp so the row still has a date.
createdAt: placement.checkout.isPaseoOwnedWorktree ? sourceAgent.createdAt : null,
isMainCheckout,
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
})

View File

@@ -10,6 +10,7 @@ import {
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import * as Clipboard from "expo-clipboard";
import {
Bot,
ChevronDown,
@@ -35,10 +36,19 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { TerminalPane } from "@/components/terminal-pane";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -52,6 +62,7 @@ import {
buildHostWorkspaceRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceTerminalRoute,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
@@ -66,11 +77,13 @@ 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";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const DROPDOWN_WIDTH = 220;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
const EMPTY_TAB_ORDER: string[] = [];
type TabAvailability = "available" | "invalid" | "unknown";
@@ -99,6 +112,41 @@ type WorkspaceTabDescriptor =
subtitle: string;
};
function applyWorkspaceTabOrder(input: {
tabs: WorkspaceTabDescriptor[];
keys: string[];
}): WorkspaceTabDescriptor[] {
if (input.keys.length === 0) {
return input.tabs;
}
const byKey = new Map<string, WorkspaceTabDescriptor>();
for (const tab of input.tabs) {
byKey.set(tab.key, tab);
}
const used = new Set<string>();
const next: WorkspaceTabDescriptor[] = [];
for (const key of input.keys) {
const tab = byKey.get(key);
if (!tab) {
continue;
}
used.add(key);
next.push(tab);
}
for (const tab of input.tabs) {
if (used.has(tab.key)) {
continue;
}
next.push(tab);
}
return next;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -266,12 +314,13 @@ function WorkspaceScreenContent({
routeTab,
}: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const toast = useToast();
const router = useRouter();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
const normalizedWorkspaceId = trimNonEmpty(decodeSegment(workspaceId)) ?? "";
const normalizedWorkspaceId = decodeWorkspaceIdFromPathSegment(workspaceId) ?? "";
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
@@ -517,7 +566,7 @@ function WorkspaceScreenContent({
return map;
}, [workspaceAgents]);
const tabs = useMemo<WorkspaceTabDescriptor[]>(() => {
const baseTabs = useMemo<WorkspaceTabDescriptor[]>(() => {
const next: WorkspaceTabDescriptor[] = [];
for (const agent of workspaceAgents) {
@@ -565,12 +614,24 @@ function WorkspaceScreenContent({
}),
[normalizedServerId, normalizedWorkspaceId]
);
const tabOrder = useWorkspaceTabsStore((state) =>
persistenceKey
? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER
: EMPTY_TAB_ORDER
);
const lastFocusedTabByWorkspace = useWorkspaceTabsStore(
(state) => state.lastFocusedTabByWorkspace
);
const setLastFocusedTab = useWorkspaceTabsStore(
(state) => state.setLastFocusedTab
);
const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder);
const tabs = useMemo(
() => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
[baseTabs, tabOrder]
);
const storedTab = useMemo(() => {
if (!persistenceKey) {
@@ -590,6 +651,17 @@ function WorkspaceScreenContent({
return { kind: "terminal", terminalId: first.terminalId };
}, [tabs]);
const handleReorderTabs = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
setTabOrder({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
keys: nextTabs.map((tab) => tab.key),
});
},
[normalizedServerId, normalizedWorkspaceId, setTabOrder]
);
const requestedTabAvailability = useMemo<TabAvailability | null>(() => {
if (!requestedTab) {
return null;
@@ -964,6 +1036,144 @@ function WorkspaceScreenContent({
]
);
const handleCopyAgentId = useCallback(
async (agentId: string) => {
if (!agentId) return;
try {
await Clipboard.setStringAsync(agentId);
toast.copied("Agent ID");
} catch {
toast.error("Copy failed");
}
},
[toast]
);
const handleCopyResumeCommand = useCallback(
async (agentId: string) => {
if (!agentId) return;
const agent = agentsById.get(agentId) ?? null;
const providerSessionId =
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
if (!agent || !providerSessionId) {
toast.error("Resume ID not available");
return;
}
const command =
buildProviderCommand({
provider: agent.provider,
id: "resume",
sessionId: providerSessionId,
}) ?? null;
if (!command) {
toast.error("Resume command not available");
return;
}
try {
await Clipboard.setStringAsync(command);
toast.copied("resume command");
} catch {
toast.error("Copy failed");
}
},
[agentsById, toast]
);
const handleCloseTabsToRight = useCallback(
async (tabKey: string) => {
const startIndex = tabs.findIndex((tab) => tab.key === tabKey);
if (startIndex < 0) {
return;
}
const toClose = tabs.slice(startIndex + 1);
if (toClose.length === 0) {
return;
}
const agentIds: string[] = [];
const terminalIdsToClose: string[] = [];
for (const tab of toClose) {
if (tab.kind === "agent") {
agentIds.push(tab.agentId);
} else {
terminalIdsToClose.push(tab.terminalId);
}
}
const confirmed = await confirmDialog({
title: "Close tabs to the right?",
message:
agentIds.length > 0 && terminalIdsToClose.length > 0
? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
: terminalIdsToClose.length > 0
? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
: `This will archive ${agentIds.length} agent(s).`,
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
for (const terminalId of terminalIdsToClose) {
try {
await killTerminalMutation.mutateAsync(terminalId);
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
if (!current) {
return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
});
} catch (error) {
console.warn("[WorkspaceScreen] Failed to close terminal tab to the right", { terminalId, error });
}
}
for (const agentId of agentIds) {
if (!normalizedServerId) {
continue;
}
try {
await archiveAgent({ serverId: normalizedServerId, agentId });
} catch (error) {
console.warn("[WorkspaceScreen] Failed to archive agent tab to the right", { agentId, error });
}
}
const resolvedTabKey = resolvedTab?.kind === "agent"
? `agent:${resolvedTab.agentId}`
: resolvedTab?.kind === "terminal"
? `terminal:${resolvedTab.terminalId}`
: null;
const closedKeys = new Set(toClose.map((tab) => tab.key));
if (resolvedTabKey && closedKeys.has(resolvedTabKey)) {
const target = tabByKey.get(tabKey);
if (target) {
navigateToTab(target);
}
}
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
archiveAgent,
killTerminalMutation,
navigateToTab,
normalizedServerId,
queryClient,
resolvedTab,
tabByKey,
tabs,
terminalsQueryKey,
]
);
const handleOpenAgentChatView = useCallback(() => {
if (!activeAgent) {
return;
@@ -1253,155 +1463,229 @@ function WorkspaceScreenContent({
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
{tabs.map((tab) => {
const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isTabHovered = hoveredTabKey === tab.key;
const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.agentId,
});
const isClosingTerminal =
tab.kind === "terminal" &&
killTerminalMutation.isPending &&
killTerminalMutation.variables === tab.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = true;
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>
) : (
<Terminal size={14} color={iconColor} />
);
<SortableInlineList
data={tabs}
keyExtractor={(tab) => tab.key}
useDragHandle
disabled={tabs.length < 2}
onDragEnd={handleReorderTabs}
renderItem={({ item: tab, dragHandleProps }) => {
const isActive = tab.key === activeTabKey;
const tabAgent =
tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.agentId,
});
const isClosingTerminal =
tab.kind === "terminal" &&
killTerminalMutation.isPending &&
killTerminalMutation.variables === tab.terminalId;
const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = true;
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>
) : (
<Terminal size={14} color={iconColor} />
);
return (
<Pressable
key={tab.key}
testID={`workspace-tab-${tab.key}`}
style={({ hovered, pressed }) => [
styles.tab,
isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
}}
onPress={() => {
if (tab.kind === "agent") {
navigateToTab({ kind: "agent", agentId: tab.agentId });
return;
}
navigateToTab({
kind: "terminal",
terminalId: tab.terminalId,
});
}}
>
<View style={styles.tabIcon}>{icon}</View>
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
numberOfLines={1}
>
{tab.label}
</Text>
<Pressable
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: `workspace-terminal-close-${tab.terminalId}`
}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
setHoveredCloseTabKey((current) =>
current === tab.key ? null : current
);
}}
onPress={(event) => {
event.stopPropagation?.();
if (tab.kind === "agent") {
void handleCloseAgentTab(tab.agentId);
return;
}
void handleCloseTerminalTab(tab.terminalId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
shouldShowCloseButton
? styles.tabCloseButtonShown
: styles.tabCloseButtonHidden,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{isClosingTab ? (
<ActivityIndicator
size={12}
color={theme.colors.foregroundMuted}
/>
) : (
<X size={12} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</Pressable>
);
})}
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
return (
<ContextMenu key={tab.key}>
<ContextMenuTrigger
testID={`workspace-tab-${tab.key}`}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered,
]}
onHoverIn={() => {
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
}}
onPress={() => {
if (tab.kind === "agent") {
navigateToTab({ kind: "agent", agentId: tab.agentId });
return;
}
navigateToTab({
kind: "terminal",
terminalId: tab.terminalId,
});
}}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={(node: unknown) => {
dragHandleProps?.setActivatorNodeRef?.(node);
}}
style={styles.tabHandle}
>
<View style={styles.tabIcon}>{icon}</View>
<Text
style={[
styles.tabLabel,
isActive && styles.tabLabelActive,
shouldShowCloseButton && styles.tabLabelWithCloseButton,
]}
numberOfLines={1}
>
{tab.label}
</Text>
</View>
<Pressable
testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: `workspace-terminal-close-${tab.terminalId}`
}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTab}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
setHoveredCloseTabKey((current) =>
current === tab.key ? null : current
);
}}
onPress={(event) => {
event.stopPropagation?.();
if (tab.kind === "agent") {
void handleCloseAgentTab(tab.agentId);
return;
}
void handleCloseTerminalTab(tab.terminalId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
shouldShowCloseButton
? styles.tabCloseButtonShown
: styles.tabCloseButtonHidden,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{isClosingTab ? (
<ActivityIndicator
size={12}
color={theme.colors.foregroundMuted}
/>
) : (
<X size={12} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</ContextMenuTrigger>
<ContextMenuContent
align="start"
width={DROPDOWN_WIDTH}
testID={contextMenuTestId}
>
{tab.kind === "agent" ? (
<>
<ContextMenuItem
testID={`${contextMenuTestId}-copy-resume-command`}
onSelect={() => {
void handleCopyResumeCommand(tab.agentId);
}}
>
Copy resume command
</ContextMenuItem>
<ContextMenuItem
testID={`${contextMenuTestId}-copy-agent-id`}
onSelect={() => {
void handleCopyAgentId(tab.agentId);
}}
>
Copy agent id
</ContextMenuItem>
</>
) : null}
<ContextMenuSeparator />
<ContextMenuItem
testID={`${contextMenuTestId}-close-right`}
disabled={
tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1
}
onSelect={() => {
void handleCloseTabsToRight(tab.key);
}}
>
Close to the right
</ContextMenuItem>
<ContextMenuItem
testID={`${contextMenuTestId}-close`}
onSelect={() => {
if (tab.kind === "agent") {
void handleCloseAgentTab(tab.agentId);
return;
}
void handleCloseTerminalTab(tab.terminalId);
}}
>
Close
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
</ScrollView>
<View style={styles.tabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -1644,6 +1928,13 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
maxWidth: 260,
},
tabHandle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
tabIcon: {
flexShrink: 0,
},

View File

@@ -6,6 +6,22 @@ export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string };
function normalizeKeys(keys: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const rawKey of keys) {
const key = rawKey.trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
normalized.push(key);
}
return normalized;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -51,6 +67,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
type WorkspaceTabsState = {
lastFocusedTabByWorkspace: Record<string, WorkspaceTabTarget>;
tabOrderByWorkspace: Record<string, string[]>;
setLastFocusedTab: (input: {
serverId: string;
workspaceId: string;
@@ -60,12 +77,18 @@ type WorkspaceTabsState = {
serverId: string;
workspaceId: string;
}) => WorkspaceTabTarget | null;
setTabOrder: (input: {
serverId: string;
workspaceId: string;
keys: string[];
}) => void;
};
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
persist(
(set, get) => ({
lastFocusedTabByWorkspace: {},
tabOrderByWorkspace: {},
setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTab = normalizeWorkspaceTab(tab);
@@ -103,15 +126,45 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
const value = get().lastFocusedTabByWorkspace[key];
return normalizeWorkspaceTab(value);
},
setTabOrder: ({ serverId, workspaceId, keys }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return;
}
const normalized = normalizeKeys(keys);
set((state) => {
const current = state.tabOrderByWorkspace[key] ?? [];
if (current.length === normalized.length) {
let isSame = true;
for (let index = 0; index < current.length; index += 1) {
if (current[index] !== normalized[index]) {
isSame = false;
break;
}
}
if (isSame) {
return state;
}
}
return {
tabOrderByWorkspace: {
...state.tabOrderByWorkspace,
[key]: normalized,
},
};
});
},
}),
{
name: "workspace-tabs-state",
version: 1,
version: 2,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState) => {
const state = persistedState as
| {
lastFocusedTabByWorkspace?: Record<string, WorkspaceTabTarget>;
tabOrderByWorkspace?: Record<string, string[]>;
}
| undefined;
@@ -126,9 +179,23 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
}
}
const rawOrder = state?.tabOrderByWorkspace ?? {};
const nextOrder: Record<string, string[]> = {};
for (const key in rawOrder) {
const list = rawOrder[key];
if (!Array.isArray(list)) {
continue;
}
const normalized = normalizeKeys(list.map((value) => String(value)));
if (normalized.length > 0) {
nextOrder[key] = normalized;
}
}
return {
...state,
lastFocusedTabByWorkspace: next,
tabOrderByWorkspace: nextOrder,
};
},
}

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
encodeWorkspaceIdForPathSegment,
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseHostDraftRouteFromPathname,
@@ -46,9 +49,14 @@ describe("parseHostAgentRouteFromPathname", () => {
});
describe("workspace route parsing", () => {
it("encodes workspace IDs as base64url (no padding)", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv");
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
});
it("parses workspace route", () => {
expect(
parseHostWorkspaceRouteFromPathname("/h/local/workspace/%2Ftmp%2Frepo")
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
@@ -58,7 +66,7 @@ describe("workspace route parsing", () => {
it("parses workspace agent route", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
"/h/local/workspace/L3RtcC9yZXBv/agent/agent-1"
)
).toEqual({
serverId: "local",
@@ -70,7 +78,7 @@ describe("workspace route parsing", () => {
it("parses workspace terminal route", () => {
expect(
parseHostWorkspaceTerminalRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/terminal/term-1"
"/h/local/workspace/L3RtcC9yZXBv/terminal/term-1"
)
).toEqual({
serverId: "local",
@@ -78,4 +86,22 @@ describe("workspace route parsing", () => {
terminalId: "term-1",
});
});
it("still parses legacy percent-encoded workspace routes", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
)
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
agentId: "agent-1",
});
});
it("builds base64url workspace routes", () => {
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe(
"/h/local/workspace/L3RtcC9yZXBv"
);
});
});

View File

@@ -1,3 +1,5 @@
import { Buffer } from "buffer";
type NullableString = string | null | undefined;
function trimNonEmpty(value: NullableString): string | null {
@@ -20,6 +22,78 @@ function decodeSegment(value: string): string {
}
}
function toBase64UrlNoPad(input: string): string {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
const normalized = input.trim();
if (normalized.length === 0) {
return null;
}
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
return null;
}
const base64 = normalized.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
let decoded: string;
try {
decoded = Buffer.from(padded, "base64").toString("utf8");
} catch {
return null;
}
// Validate via round-trip to avoid false positives ("workspace-1" etc).
if (toBase64UrlNoPad(decoded) !== normalized) {
return null;
}
return decoded;
}
function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
}
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
const normalized = trimNonEmpty(workspaceId);
if (!normalized) {
return "";
}
return toBase64UrlNoPad(normalizeWorkspaceId(normalized));
}
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
const normalizedSegment = trimNonEmpty(workspaceIdSegment);
if (!normalizedSegment) {
return null;
}
// Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies.
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) {
return null;
}
// Legacy: if it already looks like a path after decoding, keep it.
if (decoded.includes("/") || decoded.includes("\\")) {
return normalizeWorkspaceId(decoded);
}
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded) {
return normalizeWorkspaceId(base64Decoded);
}
return normalizeWorkspaceId(decoded);
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
@@ -105,17 +179,42 @@ export function buildHostDraftRoute(serverId: string): string {
export function parseHostWorkspaceRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/workspace\/([^/]+)(?:\/|$)/);
if (!match) {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const [, encodedServerId, encodedWorkspaceId] = match;
if (!encodedServerId || !encodedWorkspaceId) {
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
if (!serverId || !workspaceId) {
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
const serverId = trimNonEmpty(decodeSegment(rawServerId));
if (!serverId) {
return null;
}
const workspacePrefix = "/workspace/";
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
return null;
}
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
let workspaceIdEnd = pathname.length;
const agentIdx = pathname.lastIndexOf("/agent/");
if (agentIdx >= 0 && agentIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, agentIdx);
}
const terminalIdx = pathname.lastIndexOf("/terminal/");
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
if (!workspaceId) {
return null;
}
return { serverId, workspaceId };
@@ -124,44 +223,100 @@ export function parseHostWorkspaceRouteFromPathname(
export function parseHostWorkspaceAgentRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; agentId: string } | null {
const match = pathname.match(
/^\/h\/([^/]+)\/workspace\/([^/]+)\/agent\/([^/]+)(?:\/|$)/
);
if (!match) {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const [, encodedServerId, encodedWorkspaceId, encodedAgentId] = match;
if (!encodedServerId || !encodedWorkspaceId || !encodedAgentId) {
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
if (!serverId || !workspaceId || !agentId) {
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
const serverId = trimNonEmpty(decodeSegment(rawServerId));
if (!serverId) {
return null;
}
const workspacePrefix = "/workspace/";
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
return null;
}
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
const agentMarker = "/agent/";
const agentIdx = pathname.lastIndexOf(agentMarker);
if (agentIdx < 0 || agentIdx <= workspaceIdStart) {
return null;
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, agentIdx).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
if (!workspaceId) {
return null;
}
const agentIdStart = agentIdx + agentMarker.length;
const agentIdEnd = pathname.indexOf("/", agentIdStart);
const rawAgentId =
agentIdEnd < 0 ? pathname.slice(agentIdStart) : pathname.slice(agentIdStart, agentIdEnd);
const agentId = trimNonEmpty(decodeSegment(rawAgentId));
if (!agentId) {
return null;
}
return { serverId, workspaceId, agentId };
}
export function parseHostWorkspaceTerminalRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; terminalId: string } | null {
const match = pathname.match(
/^\/h\/([^/]+)\/workspace\/([^/]+)\/terminal\/([^/]+)(?:\/|$)/
);
if (!match) {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const [, encodedServerId, encodedWorkspaceId, encodedTerminalId] = match;
if (!encodedServerId || !encodedWorkspaceId || !encodedTerminalId) {
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
const terminalId = trimNonEmpty(decodeSegment(encodedTerminalId));
if (!serverId || !workspaceId || !terminalId) {
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
const serverId = trimNonEmpty(decodeSegment(rawServerId));
if (!serverId) {
return null;
}
const workspacePrefix = "/workspace/";
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
return null;
}
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
const terminalMarker = "/terminal/";
const terminalIdx = pathname.lastIndexOf(terminalMarker);
if (terminalIdx < 0 || terminalIdx <= workspaceIdStart) {
return null;
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, terminalIdx).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
if (!workspaceId) {
return null;
}
const terminalIdStart = terminalIdx + terminalMarker.length;
const terminalIdEnd = pathname.indexOf("/", terminalIdStart);
const rawTerminalId =
terminalIdEnd < 0
? pathname.slice(terminalIdStart)
: pathname.slice(terminalIdStart, terminalIdEnd);
const terminalId = trimNonEmpty(decodeSegment(rawTerminalId));
if (!terminalId) {
return null;
}
return { serverId, workspaceId, terminalId };
}
@@ -174,9 +329,11 @@ export function buildHostWorkspaceRoute(
if (!normalizedServerId || !normalizedWorkspaceId) {
return "/";
}
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(
normalizedWorkspaceId
)}`;
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
if (!encodedWorkspaceId) {
return "/";
}
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
}
export function buildHostWorkspaceAgentRoute(

View File

@@ -0,0 +1,40 @@
export type ProviderCommandId = "resume";
/**
* Declarative command templates for provider-native CLIs.
*
* Note: these are NOT Paseo agent IDs. They take provider-native session IDs.
* Example placeholders:
* - {sessionId}
*/
export const PROVIDER_COMMAND_TEMPLATES: Record<
string,
Partial<Record<ProviderCommandId, string>>
> = {
codex: {
resume: "codex resume {sessionId}",
},
claude: {
resume: "claude --resume {sessionId}",
},
};
function renderTemplate(
template: string,
vars: Record<string, string>
): string {
return template.replace(/\{(\w+)\}/g, (_match, key: string) => vars[key] ?? "");
}
export function buildProviderCommand(input: {
provider: string;
id: ProviderCommandId;
sessionId: string;
}): string | null {
const template = PROVIDER_COMMAND_TEMPLATES[input.provider]?.[input.id] ?? null;
if (!template) {
return null;
}
return renderTemplate(template, { sessionId: input.sessionId });
}

View File

@@ -56,16 +56,14 @@ const shouldRun = !process.env.CI;
}, 60000);
test(
"lists terminals for a directory (auto-creates first)",
"lists terminals for a directory (empty when none exist)",
async () => {
const cwd = tmpCwd();
const result = await ctx.client.listTerminals(cwd);
expect(result.cwd).toBe(cwd);
expect(result.terminals).toHaveLength(1);
expect(result.terminals[0].name).toBe("Terminal 1");
expect(result.terminals[0].id).toBeTruthy();
expect(result.terminals).toHaveLength(0);
rmSync(cwd, { recursive: true, force: true });
},
@@ -73,14 +71,11 @@ const shouldRun = !process.env.CI;
);
test(
"creates additional terminal with custom name",
"creates terminal with custom name",
async () => {
const cwd = tmpCwd();
// First call auto-creates Terminal 1
await ctx.client.listTerminals(cwd);
// Create a second terminal with custom name
// Create a terminal with custom name
const result = await ctx.client.createTerminal(cwd, "Dev Server");
expect(result.error).toBeNull();
@@ -88,9 +83,9 @@ const shouldRun = !process.env.CI;
expect(result.terminal!.name).toBe("Dev Server");
expect(result.terminal!.cwd).toBe(cwd);
// Verify list now shows two terminals
// Verify list now shows one terminal
const list = await ctx.client.listTerminals(cwd);
expect(list.terminals).toHaveLength(2);
expect(list.terminals).toHaveLength(1);
rmSync(cwd, { recursive: true, force: true });
},
@@ -101,7 +96,6 @@ const shouldRun = !process.env.CI;
"emits terminals_changed for subscribed cwd when terminals are created",
async () => {
const cwd = tmpCwd();
await ctx.client.listTerminals(cwd);
const snapshots: Array<{ cwd: string; names: string[] }> = [];
const unsubscribe = ctx.client.on("terminals_changed", (message) => {
@@ -138,9 +132,8 @@ const shouldRun = !process.env.CI;
async () => {
const cwd = tmpCwd();
// Get terminal (auto-creates)
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
// Subscribe to terminal
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
@@ -166,9 +159,8 @@ const shouldRun = !process.env.CI;
async () => {
const cwd = tmpCwd();
// Get terminal
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
// Subscribe to terminal
await ctx.client.subscribeTerminal(terminalId);
@@ -251,9 +243,8 @@ const shouldRun = !process.env.CI;
async () => {
const cwd = tmpCwd();
// Get terminal
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
// Subscribe to terminal
await ctx.client.subscribeTerminal(terminalId);
@@ -318,8 +309,8 @@ const shouldRun = !process.env.CI;
"streams terminal output over binary mux and supports modifier keys",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
expect(attach.error).toBeNull();
@@ -357,8 +348,8 @@ const shouldRun = !process.env.CI;
"emits terminal_stream_exit and removes terminal when shell exits",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
expect(attach.error).toBeNull();
@@ -395,8 +386,8 @@ const shouldRun = !process.env.CI;
"replays detached terminal output from resume offset (scrollback continuity)",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
expect(attach.error).toBeNull();
@@ -448,8 +439,8 @@ const shouldRun = !process.env.CI;
"applies stream backpressure window until client ack advances",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`);
await new Promise<void>((resolve, reject) => {
@@ -646,8 +637,8 @@ const shouldRun = !process.env.CI;
"measures local terminal round-trip latency via daemon client stream",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const attach = await ctx.client.attachTerminalStream(terminalId);
expect(attach.error).toBeNull();

View File

@@ -6428,19 +6428,12 @@ export class Session {
return
}
const hadDirectoryBeforeSubscribe = this.terminalManager.listDirectories().includes(cwd)
try {
const terminals = await this.terminalManager.getTerminals(cwd)
for (const terminal of terminals) {
this.ensureTerminalExitSubscription(terminal)
}
// New directories auto-create Terminal 1, which already emits through
// terminal-manager change listeners.
if (!hadDirectoryBeforeSubscribe) {
return
}
if (!this.subscribedTerminalDirectories.has(cwd)) {
return
}

View File

@@ -50,22 +50,22 @@ describe("TerminalManager", () => {
});
describe("getTerminals", () => {
it("auto-creates first terminal for new cwd", async () => {
it("returns empty list for new cwd", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
expect(terminals.length).toBe(1);
expect(terminals[0].name).toBe("Terminal 1");
expect(terminals[0].cwd).toBe("/tmp");
expect(terminals).toHaveLength(0);
});
it("returns existing terminals on subsequent calls", async () => {
manager = createTerminalManager();
const created = await manager.createTerminal({ cwd: "/tmp" });
const first = await manager.getTerminals("/tmp");
const second = await manager.getTerminals("/tmp");
expect(first).toBe(second);
expect(first.length).toBe(1);
expect(first[0].id).toBe(created.id);
expect(second.length).toBe(1);
});
it("throws for relative paths", async () => {
@@ -75,8 +75,8 @@ describe("TerminalManager", () => {
it("creates separate terminals for different cwds", async () => {
manager = createTerminalManager();
const tmpTerminals = await manager.getTerminals("/tmp");
const homeTerminals = await manager.getTerminals("/home");
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
expect(tmpTerminals.length).toBe(1);
expect(homeTerminals.length).toBe(1);
@@ -87,7 +87,7 @@ describe("TerminalManager", () => {
describe("createTerminal", () => {
it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
expect(second.name).toBe("Terminal 2");
@@ -175,10 +175,10 @@ describe("TerminalManager", () => {
describe("getTerminal", () => {
it("returns terminal by id", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const found = manager.getTerminal(terminals[0].id);
const session = await manager.createTerminal({ cwd: "/tmp" });
const found = manager.getTerminal(session.id);
expect(found).toBe(terminals[0]);
expect(found).toBe(session);
});
it("returns undefined for unknown id", () => {
@@ -192,28 +192,27 @@ describe("TerminalManager", () => {
describe("killTerminal", () => {
it("removes terminal from manager", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const id = terminals[0].id;
const session = await manager.createTerminal({ cwd: "/tmp" });
const id = session.id;
manager.killTerminal(id);
expect(manager.getTerminal(id)).toBeUndefined();
});
it("keeps cwd entry when last terminal is killed (but does not auto-recreate)", async () => {
it("removes cwd entry when last terminal is killed", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const created = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(created.id);
manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
expect(manager.listDirectories()).not.toContain("/tmp");
});
it("keeps cwd entry when other terminals remain", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
await manager.createTerminal({ cwd: "/tmp" });
const second = await manager.createTerminal({ cwd: "/tmp" });
const terminals = await manager.getTerminals("/tmp");
@@ -232,9 +231,9 @@ describe("TerminalManager", () => {
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const exitedId = terminals[0].id;
terminals[0].kill();
const session = await manager.createTerminal({ cwd: "/tmp" });
const exitedId = session.id;
session.kill();
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
@@ -251,10 +250,10 @@ describe("TerminalManager", () => {
expect(manager.listDirectories()).toEqual([]);
});
it("returns all cwds that have ever had terminals", async () => {
it("returns all cwds with active terminals", async () => {
manager = createTerminalManager();
await manager.getTerminals("/tmp");
await manager.getTerminals("/home");
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/home" });
const dirs = manager.listDirectories();
expect(dirs).toContain("/tmp");
@@ -266,10 +265,10 @@ describe("TerminalManager", () => {
describe("killAll", () => {
it("kills all terminals and clears state", async () => {
manager = createTerminalManager();
const tmpTerminals = await manager.getTerminals("/tmp");
const homeTerminals = await manager.getTerminals("/home");
const tmpId = tmpTerminals[0].id;
const homeId = homeTerminals[0].id;
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
const homeSession = await manager.createTerminal({ cwd: "/home" });
const tmpId = tmpSession.id;
const homeId = homeSession.id;
manager.killAll();
@@ -290,7 +289,7 @@ describe("TerminalManager", () => {
});
});
await manager.getTerminals("/tmp");
await manager.createTerminal({ cwd: "/tmp" });
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(snapshots).toContainEqual({
@@ -315,8 +314,8 @@ describe("TerminalManager", () => {
});
});
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
const session = await manager.createTerminal({ cwd: "/tmp" });
manager.killTerminal(session.id);
expect(snapshots).toContainEqual({
cwd: "/tmp",

View File

@@ -35,7 +35,6 @@ 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("/")) {
@@ -135,28 +134,7 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd);
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({
cwd,
name: "Terminal 1",
...(inheritedEnv ? { env: inheritedEnv } : {}),
})
);
const created = [session];
terminalsByCwd.set(cwd, created);
knownDirectories.add(cwd);
emitTerminalsChanged({ cwd });
return created;
}
return [];
return terminalsByCwd.get(cwd) ?? [];
},
async createTerminal(options: {
@@ -166,7 +144,6 @@ 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);
@@ -203,14 +180,13 @@ export function createTerminalManager(): TerminalManager {
},
listDirectories(): string[] {
return Array.from(knownDirectories);
return Array.from(terminalsByCwd.keys());
},
killAll(): void {
for (const id of Array.from(terminalsById.keys())) {
removeSessionById(id, { kill: true });
}
knownDirectories.clear();
},
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {