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:
@@ -74,3 +74,5 @@ valknut-report.json/
|
|||||||
.plans/
|
.plans/
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||||
|
|
||||||
|
/artifacts
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
setWorkingDirectory,
|
setWorkingDirectory,
|
||||||
} from "./helpers/app";
|
} from "./helpers/app";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
function buildWorkspaceRoute(serverId: string, workspacePath: string): string {
|
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> {
|
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import {
|
|||||||
type ReactElement,
|
type ReactElement,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { router, useSegments } from 'expo-router'
|
import { router, usePathname, useSegments } from 'expo-router'
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import { type GestureType } from 'react-native-gesture-handler'
|
import { type GestureType } from 'react-native-gesture-handler'
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes'
|
import {
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
parseHostWorkspaceAgentRouteFromPathname,
|
||||||
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
parseHostWorkspaceTerminalRouteFromPathname,
|
||||||
|
} from '@/utils/host-routes'
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
@@ -69,6 +74,7 @@ interface ProjectRowProps {
|
|||||||
|
|
||||||
interface WorkspaceRowProps {
|
interface WorkspaceRowProps {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
|
selected: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
onLongPress: () => 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 didLongPressRef = useRef(false)
|
||||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||||
|
|
||||||
@@ -216,6 +222,7 @@ function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.workspaceRow,
|
styles.workspaceRow,
|
||||||
|
selected && styles.workspaceRowSelected,
|
||||||
hovered && styles.workspaceRowHovered,
|
hovered && styles.workspaceRowHovered,
|
||||||
pressed && styles.workspaceRowPressed,
|
pressed && styles.workspaceRowPressed,
|
||||||
]}
|
]}
|
||||||
@@ -274,6 +281,7 @@ export function SidebarAgentList({
|
|||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
|
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
|
||||||
const segments = useSegments()
|
const segments = useSegments()
|
||||||
|
const pathname = usePathname()
|
||||||
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
||||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||||
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
||||||
@@ -283,6 +291,23 @@ export function SidebarAgentList({
|
|||||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder)
|
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder)
|
||||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder)
|
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(() => {
|
useEffect(() => {
|
||||||
setCollapsedProjectKeys((prev) => {
|
setCollapsedProjectKeys((prev) => {
|
||||||
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
||||||
@@ -421,10 +446,15 @@ export function SidebarAgentList({
|
|||||||
|
|
||||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
||||||
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||||
|
const isSelected =
|
||||||
|
Boolean(serverId) &&
|
||||||
|
activeWorkspaceSelection?.serverId === serverId &&
|
||||||
|
activeWorkspaceSelection.workspaceId === item.workspace.cwd
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
workspace={item.workspace}
|
workspace={item.workspace}
|
||||||
|
selected={isSelected}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return
|
return
|
||||||
@@ -437,6 +467,7 @@ export function SidebarAgentList({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
activeWorkspaceSelection,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
isMobile,
|
isMobile,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
@@ -632,6 +663,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceRowPressed: {
|
workspaceRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
workspaceRowSelected: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
workspaceStatusDot: {
|
workspaceStatusDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -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;
|
backgroundColor?: string;
|
||||||
foregroundColor?: string;
|
foregroundColor?: string;
|
||||||
cursorColor?: string;
|
cursorColor?: string;
|
||||||
|
swipeGesturesEnabled?: boolean;
|
||||||
|
onSwipeLeft?: () => void;
|
||||||
|
onSwipeRight?: () => void;
|
||||||
onInput?: (data: string) => Promise<void> | void;
|
onInput?: (data: string) => Promise<void> | void;
|
||||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||||
onTerminalKey?: (input: {
|
onTerminalKey?: (input: {
|
||||||
@@ -45,6 +48,9 @@ export default function TerminalEmulator({
|
|||||||
backgroundColor = "#0b0b0b",
|
backgroundColor = "#0b0b0b",
|
||||||
foregroundColor = "#e6e6e6",
|
foregroundColor = "#e6e6e6",
|
||||||
cursorColor = "#e6e6e6",
|
cursorColor = "#e6e6e6",
|
||||||
|
swipeGesturesEnabled = false,
|
||||||
|
onSwipeLeft,
|
||||||
|
onSwipeRight,
|
||||||
onInput,
|
onInput,
|
||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
@@ -59,6 +65,122 @@ export default function TerminalEmulator({
|
|||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
const appliedInitialOutputRef = useRef<string | 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(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
@@ -185,6 +307,7 @@ export default function TerminalEmulator({
|
|||||||
backgroundColor,
|
backgroundColor,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
overscrollBehavior: "none",
|
overscrollBehavior: "none",
|
||||||
|
touchAction: "pan-y",
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
runtimeRef.current?.focus();
|
runtimeRef.current?.focus();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
summarizeTerminalText,
|
summarizeTerminalText,
|
||||||
terminalDebugLog,
|
terminalDebugLog,
|
||||||
} from "@/terminal/runtime/terminal-debug";
|
} from "@/terminal/runtime/terminal-debug";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import TerminalEmulator from "./terminal-emulator";
|
import TerminalEmulator from "./terminal-emulator";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
@@ -147,6 +148,10 @@ export function TerminalPane({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
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({
|
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||||
mode: "padding",
|
mode: "padding",
|
||||||
enabled: isMobile,
|
enabled: isMobile,
|
||||||
@@ -1121,6 +1126,19 @@ export function TerminalPane({
|
|||||||
backgroundColor={theme.colors.background}
|
backgroundColor={theme.colors.background}
|
||||||
foregroundColor={theme.colors.foreground}
|
foregroundColor={theme.colors.foreground}
|
||||||
cursorColor={theme.colors.foreground}
|
cursorColor={theme.colors.foreground}
|
||||||
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
|
onSwipeRight={() => {
|
||||||
|
if (!swipeGesturesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openAgentList();
|
||||||
|
}}
|
||||||
|
onSwipeLeft={() => {
|
||||||
|
if (!swipeGesturesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openFileExplorer();
|
||||||
|
}}
|
||||||
onInput={handleTerminalData}
|
onInput={handleTerminalData}
|
||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
onTerminalKey={handleTerminalKey}
|
onTerminalKey={handleTerminalKey}
|
||||||
|
|||||||
@@ -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,
|
serverId,
|
||||||
cwd: checkoutCwd,
|
cwd: checkoutCwd,
|
||||||
branchName: normalizedBranch,
|
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,
|
isMainCheckout,
|
||||||
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -35,10 +36,19 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
|
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
@@ -52,6 +62,7 @@ import {
|
|||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostWorkspaceTerminalRoute,
|
buildHostWorkspaceTerminalRoute,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
|
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
@@ -66,11 +77,13 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
|||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||||
|
const EMPTY_TAB_ORDER: string[] = [];
|
||||||
|
|
||||||
type TabAvailability = "available" | "invalid" | "unknown";
|
type TabAvailability = "available" | "invalid" | "unknown";
|
||||||
|
|
||||||
@@ -99,6 +112,41 @@ type WorkspaceTabDescriptor =
|
|||||||
subtitle: string;
|
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 {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -266,12 +314,13 @@ function WorkspaceScreenContent({
|
|||||||
routeTab,
|
routeTab,
|
||||||
}: WorkspaceScreenProps) {
|
}: WorkspaceScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||||
const normalizedWorkspaceId = trimNonEmpty(decodeSegment(workspaceId)) ?? "";
|
const normalizedWorkspaceId = decodeWorkspaceIdFromPathSegment(workspaceId) ?? "";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
|
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
|
||||||
@@ -517,7 +566,7 @@ function WorkspaceScreenContent({
|
|||||||
return map;
|
return map;
|
||||||
}, [workspaceAgents]);
|
}, [workspaceAgents]);
|
||||||
|
|
||||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(() => {
|
const baseTabs = useMemo<WorkspaceTabDescriptor[]>(() => {
|
||||||
const next: WorkspaceTabDescriptor[] = [];
|
const next: WorkspaceTabDescriptor[] = [];
|
||||||
|
|
||||||
for (const agent of workspaceAgents) {
|
for (const agent of workspaceAgents) {
|
||||||
@@ -565,12 +614,24 @@ function WorkspaceScreenContent({
|
|||||||
}),
|
}),
|
||||||
[normalizedServerId, normalizedWorkspaceId]
|
[normalizedServerId, normalizedWorkspaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabOrder = useWorkspaceTabsStore((state) =>
|
||||||
|
persistenceKey
|
||||||
|
? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER
|
||||||
|
: EMPTY_TAB_ORDER
|
||||||
|
);
|
||||||
const lastFocusedTabByWorkspace = useWorkspaceTabsStore(
|
const lastFocusedTabByWorkspace = useWorkspaceTabsStore(
|
||||||
(state) => state.lastFocusedTabByWorkspace
|
(state) => state.lastFocusedTabByWorkspace
|
||||||
);
|
);
|
||||||
const setLastFocusedTab = useWorkspaceTabsStore(
|
const setLastFocusedTab = useWorkspaceTabsStore(
|
||||||
(state) => state.setLastFocusedTab
|
(state) => state.setLastFocusedTab
|
||||||
);
|
);
|
||||||
|
const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder);
|
||||||
|
|
||||||
|
const tabs = useMemo(
|
||||||
|
() => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
|
||||||
|
[baseTabs, tabOrder]
|
||||||
|
);
|
||||||
|
|
||||||
const storedTab = useMemo(() => {
|
const storedTab = useMemo(() => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
@@ -590,6 +651,17 @@ function WorkspaceScreenContent({
|
|||||||
return { kind: "terminal", terminalId: first.terminalId };
|
return { kind: "terminal", terminalId: first.terminalId };
|
||||||
}, [tabs]);
|
}, [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>(() => {
|
const requestedTabAvailability = useMemo<TabAvailability | null>(() => {
|
||||||
if (!requestedTab) {
|
if (!requestedTab) {
|
||||||
return null;
|
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(() => {
|
const handleOpenAgentChatView = useCallback(() => {
|
||||||
if (!activeAgent) {
|
if (!activeAgent) {
|
||||||
return;
|
return;
|
||||||
@@ -1253,155 +1463,229 @@ function WorkspaceScreenContent({
|
|||||||
contentContainerStyle={styles.tabsContent}
|
contentContainerStyle={styles.tabsContent}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
<SortableInlineList
|
||||||
const isActive = tab.key === activeTabKey;
|
data={tabs}
|
||||||
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
keyExtractor={(tab) => tab.key}
|
||||||
const isTabHovered = hoveredTabKey === tab.key;
|
useDragHandle
|
||||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
disabled={tabs.length < 2}
|
||||||
const isClosingAgent =
|
onDragEnd={handleReorderTabs}
|
||||||
tab.kind === "agent" &&
|
renderItem={({ item: tab, dragHandleProps }) => {
|
||||||
isArchivingAgent({
|
const isActive = tab.key === activeTabKey;
|
||||||
serverId: normalizedServerId,
|
const tabAgent =
|
||||||
agentId: tab.agentId,
|
tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
||||||
});
|
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
||||||
const isClosingTerminal =
|
const isClosingAgent =
|
||||||
tab.kind === "terminal" &&
|
tab.kind === "agent" &&
|
||||||
killTerminalMutation.isPending &&
|
isArchivingAgent({
|
||||||
killTerminalMutation.variables === tab.terminalId;
|
serverId: normalizedServerId,
|
||||||
const isClosingTab = isClosingAgent || isClosingTerminal;
|
agentId: tab.agentId,
|
||||||
const shouldShowCloseButton = true;
|
});
|
||||||
const iconColor = isActive
|
const isClosingTerminal =
|
||||||
? theme.colors.foreground
|
tab.kind === "terminal" &&
|
||||||
: theme.colors.foregroundMuted;
|
killTerminalMutation.isPending &&
|
||||||
const tabAgentStatusBucket = tabAgent
|
killTerminalMutation.variables === tab.terminalId;
|
||||||
? deriveSidebarStateBucket({
|
const isClosingTab = isClosingAgent || isClosingTerminal;
|
||||||
status: tabAgent.status,
|
const shouldShowCloseButton = true;
|
||||||
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
const iconColor = isActive
|
||||||
requiresAttention: tabAgent.requiresAttention,
|
? theme.colors.foreground
|
||||||
attentionReason: tabAgent.attentionReason,
|
: theme.colors.foregroundMuted;
|
||||||
})
|
const tabAgentStatusBucket = tabAgent
|
||||||
: null;
|
? deriveSidebarStateBucket({
|
||||||
const tabAgentStatusColor =
|
status: tabAgent.status,
|
||||||
tabAgentStatusBucket === null
|
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
||||||
? null
|
requiresAttention: tabAgent.requiresAttention,
|
||||||
: getStatusDotColor({
|
attentionReason: tabAgent.attentionReason,
|
||||||
theme,
|
})
|
||||||
bucket: tabAgentStatusBucket,
|
: null;
|
||||||
showDoneAsInactive: false,
|
const tabAgentStatusColor =
|
||||||
});
|
tabAgentStatusBucket === null
|
||||||
const icon =
|
? null
|
||||||
tab.kind === "agent" ? (
|
: getStatusDotColor({
|
||||||
<View style={styles.tabAgentIconWrapper}>
|
theme,
|
||||||
{tab.provider === "claude" ? (
|
bucket: tabAgentStatusBucket,
|
||||||
<ClaudeIcon size={14} color={iconColor} />
|
showDoneAsInactive: false,
|
||||||
) : tab.provider === "codex" ? (
|
});
|
||||||
<CodexIcon size={14} color={iconColor} />
|
const icon =
|
||||||
) : (
|
tab.kind === "agent" ? (
|
||||||
<Bot size={14} color={iconColor} />
|
<View style={styles.tabAgentIconWrapper}>
|
||||||
)}
|
{tab.provider === "claude" ? (
|
||||||
{tabAgentStatusColor ? (
|
<ClaudeIcon size={14} color={iconColor} />
|
||||||
<View
|
) : tab.provider === "codex" ? (
|
||||||
style={[
|
<CodexIcon size={14} color={iconColor} />
|
||||||
styles.tabStatusDot,
|
) : (
|
||||||
{
|
<Bot size={14} color={iconColor} />
|
||||||
backgroundColor: tabAgentStatusColor,
|
)}
|
||||||
borderColor: theme.colors.surface0,
|
{tabAgentStatusColor ? (
|
||||||
},
|
<View
|
||||||
]}
|
style={[
|
||||||
/>
|
styles.tabStatusDot,
|
||||||
) : null}
|
{
|
||||||
</View>
|
backgroundColor: tabAgentStatusColor,
|
||||||
) : (
|
borderColor: theme.colors.surface0,
|
||||||
<Terminal size={14} color={iconColor} />
|
},
|
||||||
);
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Terminal size={14} color={iconColor} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||||
<Pressable
|
|
||||||
key={tab.key}
|
return (
|
||||||
testID={`workspace-tab-${tab.key}`}
|
<ContextMenu key={tab.key}>
|
||||||
style={({ hovered, pressed }) => [
|
<ContextMenuTrigger
|
||||||
styles.tab,
|
testID={`workspace-tab-${tab.key}`}
|
||||||
isActive && styles.tabActive,
|
enabledOnMobile={false}
|
||||||
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
style={({ hovered, pressed }) => [
|
||||||
]}
|
styles.tab,
|
||||||
onHoverIn={() => {
|
isActive && styles.tabActive,
|
||||||
setHoveredTabKey(tab.key);
|
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||||
}}
|
]}
|
||||||
onHoverOut={() => {
|
onHoverIn={() => {
|
||||||
setHoveredTabKey((current) =>
|
setHoveredTabKey(tab.key);
|
||||||
current === tab.key ? null : current
|
}}
|
||||||
);
|
onHoverOut={() => {
|
||||||
}}
|
setHoveredTabKey((current) =>
|
||||||
onPress={() => {
|
current === tab.key ? null : current
|
||||||
if (tab.kind === "agent") {
|
);
|
||||||
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
}}
|
||||||
return;
|
onPress={() => {
|
||||||
}
|
if (tab.kind === "agent") {
|
||||||
navigateToTab({
|
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
||||||
kind: "terminal",
|
return;
|
||||||
terminalId: tab.terminalId,
|
}
|
||||||
});
|
navigateToTab({
|
||||||
}}
|
kind: "terminal",
|
||||||
>
|
terminalId: tab.terminalId,
|
||||||
<View style={styles.tabIcon}>{icon}</View>
|
});
|
||||||
<Text
|
}}
|
||||||
style={[
|
>
|
||||||
styles.tabLabel,
|
<View
|
||||||
isActive && styles.tabLabelActive,
|
{...(dragHandleProps?.attributes as any)}
|
||||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
{...(dragHandleProps?.listeners as any)}
|
||||||
]}
|
ref={(node: unknown) => {
|
||||||
numberOfLines={1}
|
dragHandleProps?.setActivatorNodeRef?.(node);
|
||||||
>
|
}}
|
||||||
{tab.label}
|
style={styles.tabHandle}
|
||||||
</Text>
|
>
|
||||||
<Pressable
|
<View style={styles.tabIcon}>{icon}</View>
|
||||||
testID={
|
<Text
|
||||||
tab.kind === "agent"
|
style={[
|
||||||
? `workspace-agent-close-${tab.agentId}`
|
styles.tabLabel,
|
||||||
: `workspace-terminal-close-${tab.terminalId}`
|
isActive && styles.tabLabelActive,
|
||||||
}
|
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
]}
|
||||||
disabled={!shouldShowCloseButton || isClosingTab}
|
numberOfLines={1}
|
||||||
onHoverIn={() => {
|
>
|
||||||
setHoveredTabKey(tab.key);
|
{tab.label}
|
||||||
setHoveredCloseTabKey(tab.key);
|
</Text>
|
||||||
}}
|
</View>
|
||||||
onHoverOut={() => {
|
|
||||||
setHoveredTabKey((current) =>
|
<Pressable
|
||||||
current === tab.key ? null : current
|
testID={
|
||||||
);
|
tab.kind === "agent"
|
||||||
setHoveredCloseTabKey((current) =>
|
? `workspace-agent-close-${tab.agentId}`
|
||||||
current === tab.key ? null : current
|
: `workspace-terminal-close-${tab.terminalId}`
|
||||||
);
|
}
|
||||||
}}
|
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||||
onPress={(event) => {
|
disabled={!shouldShowCloseButton || isClosingTab}
|
||||||
event.stopPropagation?.();
|
onHoverIn={() => {
|
||||||
if (tab.kind === "agent") {
|
setHoveredTabKey(tab.key);
|
||||||
void handleCloseAgentTab(tab.agentId);
|
setHoveredCloseTabKey(tab.key);
|
||||||
return;
|
}}
|
||||||
}
|
onHoverOut={() => {
|
||||||
void handleCloseTerminalTab(tab.terminalId);
|
setHoveredTabKey((current) =>
|
||||||
}}
|
current === tab.key ? null : current
|
||||||
style={({ hovered, pressed }) => [
|
);
|
||||||
styles.tabCloseButton,
|
setHoveredCloseTabKey((current) =>
|
||||||
shouldShowCloseButton
|
current === tab.key ? null : current
|
||||||
? styles.tabCloseButtonShown
|
);
|
||||||
: styles.tabCloseButtonHidden,
|
}}
|
||||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
onPress={(event) => {
|
||||||
]}
|
event.stopPropagation?.();
|
||||||
>
|
if (tab.kind === "agent") {
|
||||||
{isClosingTab ? (
|
void handleCloseAgentTab(tab.agentId);
|
||||||
<ActivityIndicator
|
return;
|
||||||
size={12}
|
}
|
||||||
color={theme.colors.foregroundMuted}
|
void handleCloseTerminalTab(tab.terminalId);
|
||||||
/>
|
}}
|
||||||
) : (
|
style={({ hovered, pressed }) => [
|
||||||
<X size={12} color={theme.colors.foregroundMuted} />
|
styles.tabCloseButton,
|
||||||
)}
|
shouldShowCloseButton
|
||||||
</Pressable>
|
? styles.tabCloseButtonShown
|
||||||
</Pressable>
|
: 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>
|
</ScrollView>
|
||||||
<View style={styles.tabsActions}>
|
<View style={styles.tabsActions}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
@@ -1644,6 +1928,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
maxWidth: 260,
|
maxWidth: 260,
|
||||||
},
|
},
|
||||||
|
tabHandle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
tabIcon: {
|
tabIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ export type WorkspaceTabTarget =
|
|||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: 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 {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -51,6 +67,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
|||||||
|
|
||||||
type WorkspaceTabsState = {
|
type WorkspaceTabsState = {
|
||||||
lastFocusedTabByWorkspace: Record<string, WorkspaceTabTarget>;
|
lastFocusedTabByWorkspace: Record<string, WorkspaceTabTarget>;
|
||||||
|
tabOrderByWorkspace: Record<string, string[]>;
|
||||||
setLastFocusedTab: (input: {
|
setLastFocusedTab: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -60,12 +77,18 @@ type WorkspaceTabsState = {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) => WorkspaceTabTarget | null;
|
}) => WorkspaceTabTarget | null;
|
||||||
|
setTabOrder: (input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
keys: string[];
|
||||||
|
}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
lastFocusedTabByWorkspace: {},
|
lastFocusedTabByWorkspace: {},
|
||||||
|
tabOrderByWorkspace: {},
|
||||||
setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
|
setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
const normalizedTab = normalizeWorkspaceTab(tab);
|
const normalizedTab = normalizeWorkspaceTab(tab);
|
||||||
@@ -103,15 +126,45 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
const value = get().lastFocusedTabByWorkspace[key];
|
const value = get().lastFocusedTabByWorkspace[key];
|
||||||
return normalizeWorkspaceTab(value);
|
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",
|
name: "workspace-tabs-state",
|
||||||
version: 1,
|
version: 2,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
migrate: (persistedState) => {
|
migrate: (persistedState) => {
|
||||||
const state = persistedState as
|
const state = persistedState as
|
||||||
| {
|
| {
|
||||||
lastFocusedTabByWorkspace?: Record<string, WorkspaceTabTarget>;
|
lastFocusedTabByWorkspace?: Record<string, WorkspaceTabTarget>;
|
||||||
|
tabOrderByWorkspace?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
| undefined;
|
| 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
lastFocusedTabByWorkspace: next,
|
lastFocusedTabByWorkspace: next,
|
||||||
|
tabOrderByWorkspace: nextOrder,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
|
encodeWorkspaceIdForPathSegment,
|
||||||
parseHostAgentDraftRouteFromPathname,
|
parseHostAgentDraftRouteFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostDraftRouteFromPathname,
|
parseHostDraftRouteFromPathname,
|
||||||
@@ -46,9 +49,14 @@ describe("parseHostAgentRouteFromPathname", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("workspace route parsing", () => {
|
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", () => {
|
it("parses workspace route", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/%2Ftmp%2Frepo")
|
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
|
||||||
).toEqual({
|
).toEqual({
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
workspaceId: "/tmp/repo",
|
workspaceId: "/tmp/repo",
|
||||||
@@ -58,7 +66,7 @@ describe("workspace route parsing", () => {
|
|||||||
it("parses workspace agent route", () => {
|
it("parses workspace agent route", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceAgentRouteFromPathname(
|
parseHostWorkspaceAgentRouteFromPathname(
|
||||||
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
|
"/h/local/workspace/L3RtcC9yZXBv/agent/agent-1"
|
||||||
)
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
@@ -70,7 +78,7 @@ describe("workspace route parsing", () => {
|
|||||||
it("parses workspace terminal route", () => {
|
it("parses workspace terminal route", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceTerminalRouteFromPathname(
|
parseHostWorkspaceTerminalRouteFromPathname(
|
||||||
"/h/local/workspace/%2Ftmp%2Frepo/terminal/term-1"
|
"/h/local/workspace/L3RtcC9yZXBv/terminal/term-1"
|
||||||
)
|
)
|
||||||
).toEqual({
|
).toEqual({
|
||||||
serverId: "local",
|
serverId: "local",
|
||||||
@@ -78,4 +86,22 @@ describe("workspace route parsing", () => {
|
|||||||
terminalId: "term-1",
|
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;
|
type NullableString = string | null | undefined;
|
||||||
|
|
||||||
function trimNonEmpty(value: NullableString): string | null {
|
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 {
|
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -105,17 +179,42 @@ export function buildHostDraftRoute(serverId: string): string {
|
|||||||
export function parseHostWorkspaceRouteFromPathname(
|
export function parseHostWorkspaceRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string } | null {
|
): { serverId: string; workspaceId: string } | null {
|
||||||
const match = pathname.match(/^\/h\/([^/]+)\/workspace\/([^/]+)(?:\/|$)/);
|
const prefix = "/h/";
|
||||||
if (!match) {
|
if (!pathname.startsWith(prefix)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const [, encodedServerId, encodedWorkspaceId] = match;
|
|
||||||
if (!encodedServerId || !encodedWorkspaceId) {
|
const serverIdStart = prefix.length;
|
||||||
|
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||||
|
if (serverIdEnd < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||||
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
|
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||||
if (!serverId || !workspaceId) {
|
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 null;
|
||||||
}
|
}
|
||||||
return { serverId, workspaceId };
|
return { serverId, workspaceId };
|
||||||
@@ -124,44 +223,100 @@ export function parseHostWorkspaceRouteFromPathname(
|
|||||||
export function parseHostWorkspaceAgentRouteFromPathname(
|
export function parseHostWorkspaceAgentRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string; agentId: string } | null {
|
): { serverId: string; workspaceId: string; agentId: string } | null {
|
||||||
const match = pathname.match(
|
const prefix = "/h/";
|
||||||
/^\/h\/([^/]+)\/workspace\/([^/]+)\/agent\/([^/]+)(?:\/|$)/
|
if (!pathname.startsWith(prefix)) {
|
||||||
);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||||
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
|
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||||
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
|
if (!serverId) {
|
||||||
if (!serverId || !workspaceId || !agentId) {
|
|
||||||
return null;
|
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 };
|
return { serverId, workspaceId, agentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHostWorkspaceTerminalRouteFromPathname(
|
export function parseHostWorkspaceTerminalRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string; terminalId: string } | null {
|
): { serverId: string; workspaceId: string; terminalId: string } | null {
|
||||||
const match = pathname.match(
|
const prefix = "/h/";
|
||||||
/^\/h\/([^/]+)\/workspace\/([^/]+)\/terminal\/([^/]+)(?:\/|$)/
|
if (!pathname.startsWith(prefix)) {
|
||||||
);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||||
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
|
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||||
const terminalId = trimNonEmpty(decodeSegment(encodedTerminalId));
|
if (!serverId) {
|
||||||
if (!serverId || !workspaceId || !terminalId) {
|
|
||||||
return null;
|
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 };
|
return { serverId, workspaceId, terminalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +329,11 @@ export function buildHostWorkspaceRoute(
|
|||||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||||
return "/";
|
return "/";
|
||||||
}
|
}
|
||||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(
|
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
|
||||||
normalizedWorkspaceId
|
if (!encodedWorkspaceId) {
|
||||||
)}`;
|
return "/";
|
||||||
|
}
|
||||||
|
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostWorkspaceAgentRoute(
|
export function buildHostWorkspaceAgentRoute(
|
||||||
|
|||||||
@@ -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);
|
}, 60000);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"lists terminals for a directory (auto-creates first)",
|
"lists terminals for a directory (empty when none exist)",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
const result = await ctx.client.listTerminals(cwd);
|
const result = await ctx.client.listTerminals(cwd);
|
||||||
|
|
||||||
expect(result.cwd).toBe(cwd);
|
expect(result.cwd).toBe(cwd);
|
||||||
expect(result.terminals).toHaveLength(1);
|
expect(result.terminals).toHaveLength(0);
|
||||||
expect(result.terminals[0].name).toBe("Terminal 1");
|
|
||||||
expect(result.terminals[0].id).toBeTruthy();
|
|
||||||
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
@@ -73,14 +71,11 @@ const shouldRun = !process.env.CI;
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"creates additional terminal with custom name",
|
"creates terminal with custom name",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// First call auto-creates Terminal 1
|
// Create a terminal with custom name
|
||||||
await ctx.client.listTerminals(cwd);
|
|
||||||
|
|
||||||
// Create a second terminal with custom name
|
|
||||||
const result = await ctx.client.createTerminal(cwd, "Dev Server");
|
const result = await ctx.client.createTerminal(cwd, "Dev Server");
|
||||||
|
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
@@ -88,9 +83,9 @@ const shouldRun = !process.env.CI;
|
|||||||
expect(result.terminal!.name).toBe("Dev Server");
|
expect(result.terminal!.name).toBe("Dev Server");
|
||||||
expect(result.terminal!.cwd).toBe(cwd);
|
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);
|
const list = await ctx.client.listTerminals(cwd);
|
||||||
expect(list.terminals).toHaveLength(2);
|
expect(list.terminals).toHaveLength(1);
|
||||||
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
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",
|
"emits terminals_changed for subscribed cwd when terminals are created",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
await ctx.client.listTerminals(cwd);
|
|
||||||
|
|
||||||
const snapshots: Array<{ cwd: string; names: string[] }> = [];
|
const snapshots: Array<{ cwd: string; names: string[] }> = [];
|
||||||
const unsubscribe = ctx.client.on("terminals_changed", (message) => {
|
const unsubscribe = ctx.client.on("terminals_changed", (message) => {
|
||||||
@@ -138,9 +132,8 @@ const shouldRun = !process.env.CI;
|
|||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// Get terminal (auto-creates)
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const terminalId = created.terminal!.id;
|
||||||
const terminalId = list.terminals[0].id;
|
|
||||||
|
|
||||||
// Subscribe to terminal
|
// Subscribe to terminal
|
||||||
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
|
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
|
||||||
@@ -166,9 +159,8 @@ const shouldRun = !process.env.CI;
|
|||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// Get terminal
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const terminalId = created.terminal!.id;
|
||||||
const terminalId = list.terminals[0].id;
|
|
||||||
|
|
||||||
// Subscribe to terminal
|
// Subscribe to terminal
|
||||||
await ctx.client.subscribeTerminal(terminalId);
|
await ctx.client.subscribeTerminal(terminalId);
|
||||||
@@ -251,9 +243,8 @@ const shouldRun = !process.env.CI;
|
|||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
|
|
||||||
// Get terminal
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const terminalId = created.terminal!.id;
|
||||||
const terminalId = list.terminals[0].id;
|
|
||||||
|
|
||||||
// Subscribe to terminal
|
// Subscribe to terminal
|
||||||
await ctx.client.subscribeTerminal(terminalId);
|
await ctx.client.subscribeTerminal(terminalId);
|
||||||
@@ -318,8 +309,8 @@ const shouldRun = !process.env.CI;
|
|||||||
"streams terminal output over binary mux and supports modifier keys",
|
"streams terminal output over binary mux and supports modifier keys",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const terminalId = list.terminals[0].id;
|
const terminalId = created.terminal!.id;
|
||||||
|
|
||||||
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||||
expect(attach.error).toBeNull();
|
expect(attach.error).toBeNull();
|
||||||
@@ -357,8 +348,8 @@ const shouldRun = !process.env.CI;
|
|||||||
"emits terminal_stream_exit and removes terminal when shell exits",
|
"emits terminal_stream_exit and removes terminal when shell exits",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const terminalId = list.terminals[0].id;
|
const terminalId = created.terminal!.id;
|
||||||
|
|
||||||
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||||
expect(attach.error).toBeNull();
|
expect(attach.error).toBeNull();
|
||||||
@@ -395,8 +386,8 @@ const shouldRun = !process.env.CI;
|
|||||||
"replays detached terminal output from resume offset (scrollback continuity)",
|
"replays detached terminal output from resume offset (scrollback continuity)",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const terminalId = list.terminals[0].id;
|
const terminalId = created.terminal!.id;
|
||||||
|
|
||||||
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
|
||||||
expect(attach.error).toBeNull();
|
expect(attach.error).toBeNull();
|
||||||
@@ -448,8 +439,8 @@ const shouldRun = !process.env.CI;
|
|||||||
"applies stream backpressure window until client ack advances",
|
"applies stream backpressure window until client ack advances",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const terminalId = list.terminals[0].id;
|
const terminalId = created.terminal!.id;
|
||||||
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`);
|
const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`);
|
||||||
await new Promise<void>((resolve, reject) => {
|
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",
|
"measures local terminal round-trip latency via daemon client stream",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const list = await ctx.client.listTerminals(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const terminalId = list.terminals[0].id;
|
const terminalId = created.terminal!.id;
|
||||||
|
|
||||||
const attach = await ctx.client.attachTerminalStream(terminalId);
|
const attach = await ctx.client.attachTerminalStream(terminalId);
|
||||||
expect(attach.error).toBeNull();
|
expect(attach.error).toBeNull();
|
||||||
|
|||||||
@@ -6428,19 +6428,12 @@ export class Session {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hadDirectoryBeforeSubscribe = this.terminalManager.listDirectories().includes(cwd)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const terminals = await this.terminalManager.getTerminals(cwd)
|
const terminals = await this.terminalManager.getTerminals(cwd)
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
this.ensureTerminalExitSubscription(terminal)
|
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)) {
|
if (!this.subscribedTerminalDirectories.has(cwd)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,22 +50,22 @@ describe("TerminalManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getTerminals", () => {
|
describe("getTerminals", () => {
|
||||||
it("auto-creates first terminal for new cwd", async () => {
|
it("returns empty list for new cwd", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const terminals = await manager.getTerminals("/tmp");
|
||||||
|
|
||||||
expect(terminals.length).toBe(1);
|
expect(terminals).toHaveLength(0);
|
||||||
expect(terminals[0].name).toBe("Terminal 1");
|
|
||||||
expect(terminals[0].cwd).toBe("/tmp");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns existing terminals on subsequent calls", async () => {
|
it("returns existing terminals on subsequent calls", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
|
const created = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const first = await manager.getTerminals("/tmp");
|
const first = await manager.getTerminals("/tmp");
|
||||||
const second = await manager.getTerminals("/tmp");
|
const second = await manager.getTerminals("/tmp");
|
||||||
|
|
||||||
expect(first).toBe(second);
|
|
||||||
expect(first.length).toBe(1);
|
expect(first.length).toBe(1);
|
||||||
|
expect(first[0].id).toBe(created.id);
|
||||||
|
expect(second.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws for relative paths", async () => {
|
it("throws for relative paths", async () => {
|
||||||
@@ -75,8 +75,8 @@ describe("TerminalManager", () => {
|
|||||||
|
|
||||||
it("creates separate terminals for different cwds", async () => {
|
it("creates separate terminals for different cwds", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
||||||
const homeTerminals = await manager.getTerminals("/home");
|
const homeTerminals = [await manager.createTerminal({ cwd: "/home" })];
|
||||||
|
|
||||||
expect(tmpTerminals.length).toBe(1);
|
expect(tmpTerminals.length).toBe(1);
|
||||||
expect(homeTerminals.length).toBe(1);
|
expect(homeTerminals.length).toBe(1);
|
||||||
@@ -87,7 +87,7 @@ describe("TerminalManager", () => {
|
|||||||
describe("createTerminal", () => {
|
describe("createTerminal", () => {
|
||||||
it("creates additional terminal with auto-incrementing name", async () => {
|
it("creates additional terminal with auto-incrementing name", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await manager.getTerminals("/tmp");
|
await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
|
|
||||||
expect(second.name).toBe("Terminal 2");
|
expect(second.name).toBe("Terminal 2");
|
||||||
@@ -175,10 +175,10 @@ describe("TerminalManager", () => {
|
|||||||
describe("getTerminal", () => {
|
describe("getTerminal", () => {
|
||||||
it("returns terminal by id", async () => {
|
it("returns terminal by id", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const found = manager.getTerminal(terminals[0].id);
|
const found = manager.getTerminal(session.id);
|
||||||
|
|
||||||
expect(found).toBe(terminals[0]);
|
expect(found).toBe(session);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined for unknown id", () => {
|
it("returns undefined for unknown id", () => {
|
||||||
@@ -192,28 +192,27 @@ describe("TerminalManager", () => {
|
|||||||
describe("killTerminal", () => {
|
describe("killTerminal", () => {
|
||||||
it("removes terminal from manager", async () => {
|
it("removes terminal from manager", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const id = terminals[0].id;
|
const id = session.id;
|
||||||
|
|
||||||
manager.killTerminal(id);
|
manager.killTerminal(id);
|
||||||
|
|
||||||
expect(manager.getTerminal(id)).toBeUndefined();
|
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();
|
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");
|
const remaining = await manager.getTerminals("/tmp");
|
||||||
expect(remaining).toHaveLength(0);
|
expect(remaining).toHaveLength(0);
|
||||||
|
expect(manager.listDirectories()).not.toContain("/tmp");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps cwd entry when other terminals remain", async () => {
|
it("keeps cwd entry when other terminals remain", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await manager.getTerminals("/tmp");
|
await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
|
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const terminals = await manager.getTerminals("/tmp");
|
||||||
@@ -232,9 +231,9 @@ describe("TerminalManager", () => {
|
|||||||
|
|
||||||
it("auto-removes terminal when shell exits", async () => {
|
it("auto-removes terminal when shell exits", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const exitedId = terminals[0].id;
|
const exitedId = session.id;
|
||||||
terminals[0].kill();
|
session.kill();
|
||||||
|
|
||||||
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
|
||||||
|
|
||||||
@@ -251,10 +250,10 @@ describe("TerminalManager", () => {
|
|||||||
expect(manager.listDirectories()).toEqual([]);
|
expect(manager.listDirectories()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns all cwds that have ever had terminals", async () => {
|
it("returns all cwds with active terminals", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
await manager.getTerminals("/tmp");
|
await manager.createTerminal({ cwd: "/tmp" });
|
||||||
await manager.getTerminals("/home");
|
await manager.createTerminal({ cwd: "/home" });
|
||||||
|
|
||||||
const dirs = manager.listDirectories();
|
const dirs = manager.listDirectories();
|
||||||
expect(dirs).toContain("/tmp");
|
expect(dirs).toContain("/tmp");
|
||||||
@@ -266,10 +265,10 @@ describe("TerminalManager", () => {
|
|||||||
describe("killAll", () => {
|
describe("killAll", () => {
|
||||||
it("kills all terminals and clears state", async () => {
|
it("kills all terminals and clears state", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
const tmpSession = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
const homeTerminals = await manager.getTerminals("/home");
|
const homeSession = await manager.createTerminal({ cwd: "/home" });
|
||||||
const tmpId = tmpTerminals[0].id;
|
const tmpId = tmpSession.id;
|
||||||
const homeId = homeTerminals[0].id;
|
const homeId = homeSession.id;
|
||||||
|
|
||||||
manager.killAll();
|
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" });
|
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
||||||
|
|
||||||
expect(snapshots).toContainEqual({
|
expect(snapshots).toContainEqual({
|
||||||
@@ -315,8 +314,8 @@ describe("TerminalManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const terminals = await manager.getTerminals("/tmp");
|
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||||
manager.killTerminal(terminals[0].id);
|
manager.killTerminal(session.id);
|
||||||
|
|
||||||
expect(snapshots).toContainEqual({
|
expect(snapshots).toContainEqual({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||||
const knownDirectories = new Set<string>();
|
|
||||||
|
|
||||||
function assertAbsolutePath(cwd: string): void {
|
function assertAbsolutePath(cwd: string): void {
|
||||||
if (!cwd.startsWith("/")) {
|
if (!cwd.startsWith("/")) {
|
||||||
@@ -135,28 +134,7 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||||
assertAbsolutePath(cwd);
|
assertAbsolutePath(cwd);
|
||||||
|
|
||||||
const terminals = terminalsByCwd.get(cwd);
|
return 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 [];
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTerminal(options: {
|
async createTerminal(options: {
|
||||||
@@ -166,7 +144,6 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
}): Promise<TerminalSession> {
|
}): Promise<TerminalSession> {
|
||||||
assertAbsolutePath(options.cwd);
|
assertAbsolutePath(options.cwd);
|
||||||
|
|
||||||
knownDirectories.add(options.cwd);
|
|
||||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||||
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
||||||
@@ -203,14 +180,13 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listDirectories(): string[] {
|
listDirectories(): string[] {
|
||||||
return Array.from(knownDirectories);
|
return Array.from(terminalsByCwd.keys());
|
||||||
},
|
},
|
||||||
|
|
||||||
killAll(): void {
|
killAll(): void {
|
||||||
for (const id of Array.from(terminalsById.keys())) {
|
for (const id of Array.from(terminalsById.keys())) {
|
||||||
removeSessionById(id, { kill: true });
|
removeSessionById(id, { kill: true });
|
||||||
}
|
}
|
||||||
knownDirectories.clear();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
||||||
|
|||||||
Reference in New Issue
Block a user