mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
packages/app/src/components/sortable-inline-list.native.tsx
Normal file
44
packages/app/src/components/sortable-inline-list.native.tsx
Normal 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);
|
||||
}
|
||||
8
packages/app/src/components/sortable-inline-list.tsx
Normal file
8
packages/app/src/components/sortable-inline-list.tsx
Normal 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";
|
||||
|
||||
206
packages/app/src/components/sortable-inline-list.web.tsx
Normal file
206
packages/app/src/components/sortable-inline-list.web.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
707
packages/app/src/components/ui/context-menu.tsx
Normal file
707
packages/app/src/components/ui/context-menu.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
40
packages/app/src/utils/provider-command-templates.ts
Normal file
40
packages/app/src/utils/provider-command-templates.ts
Normal 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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user