refactor: improve drag interaction and workspace identity normalization

This commit is contained in:
Mohamed Boudra
2026-03-04 12:25:14 +07:00
parent b5b4e60916
commit 4f1783fc76
12 changed files with 750 additions and 147 deletions
@@ -6,6 +6,7 @@ import {
Platform, Platform,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
StatusBar,
ScrollView, ScrollView,
type GestureResponderEvent, type GestureResponderEvent,
} from 'react-native' } from 'react-native'
@@ -23,7 +24,7 @@ import {
import { router, usePathname, 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, GripVertical } 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 type { DraggableListDragHandleProps } from './draggable-list.types' import type { DraggableListDragHandleProps } from './draggable-list.types'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
@@ -154,33 +155,208 @@ function WorkspaceStatusIndicator({
function useLongPressDragInteraction(input: { function useLongPressDragInteraction(input: {
drag: () => void drag: () => void
menuController: ReturnType<typeof useContextMenu> | null
debugId: string
}) { }) {
const didLongPressRef = useRef(false) const didLongPressRef = useRef(false)
const dragArmedRef = useRef(false)
const didStartDragRef = useRef(false)
const scrollIntentRef = useRef(false)
const menuOpenedRef = useRef(false)
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null)
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearTimers = useCallback(() => {
if (dragArmTimerRef.current) {
clearTimeout(dragArmTimerRef.current)
dragArmTimerRef.current = null
}
if (contextMenuTimerRef.current) {
clearTimeout(contextMenuTimerRef.current)
contextMenuTimerRef.current = null
}
}, [])
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
return
}
const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
input.menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
})
input.menuController.setOpen(true)
menuOpenedRef.current = true
didLongPressRef.current = true
console.log('[sidebar-dnd-debug] context menu opened', { id: input.debugId })
}, [input.debugId, input.menuController])
const handleLongPress = useCallback(() => { const handleLongPress = useCallback(() => {
console.log('[sidebar-dnd-debug] long-press armed') // Manual timers own long-press behavior on mobile.
void Haptics.selectionAsync().catch(() => {}) console.log('[sidebar-dnd-debug] native onLongPress ignored (manual state machine active)', {
didLongPressRef.current = true id: input.debugId,
}, []) })
}, [input.debugId])
useEffect(() => {
return () => {
clearTimers()
}
}, [clearTimers])
const armTimers = useCallback(() => {
clearTimers()
const DRAG_ARM_DELAY_MS = 140
const CONTEXT_MENU_DELAY_MS = 450
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6
dragArmTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return
}
dragArmedRef.current = true
console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId })
void Haptics.selectionAsync().catch(() => {})
}, DRAG_ARM_DELAY_MS)
if (!input.menuController || Platform.OS === 'web') {
return
}
contextMenuTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return
}
const start = touchStartRef.current
const current = touchCurrentRef.current ?? start
if (!start || !current) {
return
}
const dx = current.x - start.x
const dy = current.y - start.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
console.log('[sidebar-dnd-debug] context menu cancelled (movement)', {
id: input.debugId,
distance,
})
return
}
console.log('[sidebar-dnd-debug] long-press armed', { id: input.debugId })
void Haptics.selectionAsync().catch(() => {})
openContextMenuAtStartPoint()
}, CONTEXT_MENU_DELAY_MS)
}, [clearTimers, input.debugId, input.menuController, openContextMenuAtStartPoint])
const handleDragIntent = useCallback(
(details: { dx: number; dy: number; distance: number }) => {
didStartDragRef.current = true
didLongPressRef.current = true
clearTimers()
console.log('[sidebar-dnd-debug] drag intent detected', { id: input.debugId, ...details })
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
input.drag()
},
[clearTimers, input]
)
const handleScrollIntent = useCallback(
(details: { dx: number; dy: number; distance: number }) => {
scrollIntentRef.current = true
didLongPressRef.current = true
clearTimers()
console.log('[sidebar-dnd-debug] scroll intent detected', { id: input.debugId, ...details })
},
[clearTimers, input.debugId]
)
const handlePressIn = useCallback((event: GestureResponderEvent) => { const handlePressIn = useCallback((event: GestureResponderEvent) => {
didLongPressRef.current = false didLongPressRef.current = false
dragArmedRef.current = false
didStartDragRef.current = false
scrollIntentRef.current = false
menuOpenedRef.current = false
touchStartRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
}
touchCurrentRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
}
console.log('[sidebar-dnd-debug] press-in', { console.log('[sidebar-dnd-debug] press-in', {
id: input.debugId,
x: event.nativeEvent.pageX, x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY, y: event.nativeEvent.pageY,
}) })
}, []) armTimers()
}, [armTimers, input.debugId])
const handleTouchMove = useCallback(
(event: any) => {
const start = touchStartRef.current
if (!start || didStartDragRef.current) {
return
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent
const x = touch?.pageX
const y = touch?.pageY
if (typeof x !== 'number' || typeof y !== 'number') {
return
}
touchCurrentRef.current = { x, y }
const dx = x - start.x
const dy = y - start.y
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const distance = Math.sqrt(dx * dx + dy * dy)
const SCROLL_INTENT_SLOP_PX = 8
const DRAG_START_SLOP_PX = 6
if (!scrollIntentRef.current && absDy > absDx && absDy > SCROLL_INTENT_SLOP_PX) {
handleScrollIntent({ dx, dy, distance })
return
}
if (scrollIntentRef.current) {
return
}
if (dragArmedRef.current && distance >= DRAG_START_SLOP_PX) {
handleDragIntent({ dx, dy, distance })
}
},
[handleDragIntent, handleScrollIntent]
)
const handlePressOut = useCallback(() => { const handlePressOut = useCallback(() => {
clearTimers()
console.log('[sidebar-dnd-debug] press-out no context-menu', { console.log('[sidebar-dnd-debug] press-out no context-menu', {
id: input.debugId,
didLongPress: didLongPressRef.current, didLongPress: didLongPressRef.current,
didStartDrag: didStartDragRef.current,
scrollIntent: scrollIntentRef.current,
dragArmed: dragArmedRef.current,
menuOpened: menuOpenedRef.current,
}) })
}, []) dragArmedRef.current = false
touchStartRef.current = null
touchCurrentRef.current = null
}, [clearTimers, input.debugId])
return { return {
didLongPressRef, didLongPressRef,
handleLongPress, handleLongPress,
handlePressIn, handlePressIn,
handleTouchMove,
handlePressOut, handlePressOut,
} }
} }
@@ -195,8 +371,11 @@ function ProjectHeaderRow({
isDragging, isDragging,
dragHandleProps, dragHandleProps,
}: ProjectHeaderRowProps) { }: ProjectHeaderRowProps) {
const menuController = useContextMenu()
const interaction = useLongPressDragInteraction({ const interaction = useLongPressDragInteraction({
drag, drag,
menuController,
debugId: `project:${project.projectKey}`,
}) })
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
@@ -207,15 +386,9 @@ function ProjectHeaderRow({
onToggle() onToggle()
}, [interaction.didLongPressRef, onToggle]) }, [interaction.didLongPressRef, onToggle])
const handleDragHandlePressIn = useCallback(() => {
console.log('[sidebar-dnd-debug] project drag-handle press-in')
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
drag()
}, [drag])
const trigger = ( const trigger = (
<ContextMenuTrigger <ContextMenuTrigger
enabledOnMobile enabledOnMobile={false}
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.projectRow, styles.projectRow,
isDragging && styles.projectRowDragging, isDragging && styles.projectRowDragging,
@@ -223,13 +396,17 @@ function ProjectHeaderRow({
pressed && styles.projectRowPressed, pressed && styles.projectRowPressed,
]} ]}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut} onPressOut={interaction.handlePressOut}
onPress={handlePress} onPress={handlePress}
onLongPress={interaction.handleLongPress}
delayLongPress={350}
testID={`sidebar-project-row-${project.projectKey}`} testID={`sidebar-project-row-${project.projectKey}`}
> >
<View style={styles.projectRowLeft}> <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.projectRowLeft}
>
{collapsed ? ( {collapsed ? (
<ChevronRight size={14} color="#9ca3af" /> <ChevronRight size={14} color="#9ca3af" />
) : ( ) : (
@@ -248,20 +425,6 @@ function ProjectHeaderRow({
{displayName} {displayName}
</Text> </Text>
</View> </View>
<Pressable
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
hitSlop={8}
onPressIn={handleDragHandlePressIn}
onLongPress={handleDragHandlePressIn}
delayLongPress={120}
onPress={(event) => (event as any).stopPropagation?.()}
style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]}
testID={`sidebar-project-drag-handle-${project.projectKey}`}
>
<GripVertical size={14} color="#9ca3af" />
</Pressable>
</ContextMenuTrigger> </ContextMenuTrigger>
) )
@@ -300,6 +463,8 @@ function WorkspaceRowInner({
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
const interaction = useLongPressDragInteraction({ const interaction = useLongPressDragInteraction({
drag, drag,
menuController,
debugId: `workspace:${workspace.workspaceKey}`,
}) })
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
@@ -310,17 +475,12 @@ function WorkspaceRowInner({
onPress() onPress()
}, [interaction.didLongPressRef, onPress]) }, [interaction.didLongPressRef, onPress])
const handleDragHandlePressIn = useCallback(() => {
console.log('[sidebar-dnd-debug] workspace drag-handle press-in', {
workspaceKey: workspace.workspaceKey,
})
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
drag()
}, [drag, workspace.workspaceKey])
const rowChildren = ( const rowChildren = (
<> <>
<View <View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft} style={styles.workspaceRowLeft}
> >
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} /> <WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
@@ -339,27 +499,13 @@ function WorkspaceRowInner({
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text> <Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View> </View>
) : null} ) : null}
<Pressable
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
hitSlop={8}
onPressIn={handleDragHandlePressIn}
onLongPress={handleDragHandlePressIn}
delayLongPress={120}
onPress={(event) => (event as any).stopPropagation?.()}
style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]}
testID={`sidebar-workspace-drag-handle-${workspace.workspaceKey}`}
>
<GripVertical size={14} color="#9ca3af" />
</Pressable>
</View> </View>
</> </>
) )
const trigger = menuController ? ( const trigger = menuController ? (
<ContextMenuTrigger <ContextMenuTrigger
enabledOnMobile enabledOnMobile={false}
disabled={isArchiving} disabled={isArchiving}
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.workspaceRow, styles.workspaceRow,
@@ -369,10 +515,9 @@ function WorkspaceRowInner({
pressed && styles.workspaceRowPressed, pressed && styles.workspaceRowPressed,
]} ]}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut} onPressOut={interaction.handlePressOut}
onPress={handlePress} onPress={handlePress}
onLongPress={interaction.handleLongPress}
delayLongPress={350}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
{rowChildren} {rowChildren}
@@ -388,10 +533,9 @@ function WorkspaceRowInner({
pressed && styles.workspaceRowPressed, pressed && styles.workspaceRowPressed,
]} ]}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut} onPressOut={interaction.handlePressOut}
onPress={handlePress} onPress={handlePress}
onLongPress={interaction.handleLongPress}
delayLongPress={350}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
{rowChildren} {rowChildren}
@@ -1191,22 +1335,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
dragHandle: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
flexShrink: 0,
},
dragHandlePressed: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.foregroundMuted,
transform: [{ scale: 0.96 }],
},
workspaceCreatedAtText: { workspaceCreatedAtText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
+33 -15
View File
@@ -49,6 +49,7 @@ import {
normalizeAgentSnapshot, normalizeAgentSnapshot,
} from "@/utils/agent-snapshots"; } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement"; import { resolveProjectPlacement } from "@/utils/project-placement";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { buildDraftStoreKey } from "@/stores/draft-keys"; import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
@@ -132,7 +133,7 @@ function normalizeWorkspaceDescriptor(
? new Date(payload.activityAt) ? new Date(payload.activityAt)
: null; : null;
return { return {
id: payload.id, id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
projectId: payload.projectId, projectId: payload.projectId,
name: payload.name, name: payload.name,
status: payload.status, status: payload.status,
@@ -448,26 +449,43 @@ function SessionProviderInternal({
} }
let cancelled = false; let cancelled = false;
void client void (async () => {
.fetchWorkspaces({ const workspaces = new Map<string, WorkspaceDescriptor>();
subscribe: {}, let cursor: string | null = null;
page: { limit: 200 }, let includeSubscribe = true;
})
.then((payload) => { try {
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
...(includeSubscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
if (cancelled) {
return;
}
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
includeSubscribe = false;
}
if (cancelled) { if (cancelled) {
return; return;
} }
const workspaces = new Map<string, WorkspaceDescriptor>();
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
workspaces.set(workspace.id, workspace);
}
setWorkspaces(serverId, workspaces); setWorkspaces(serverId, workspaces);
setHasHydratedWorkspaces(serverId, true); setHasHydratedWorkspaces(serverId, true);
}) } catch (error) {
.catch((error) => {
console.error("[Session] Failed to hydrate workspaces:", error); console.error("[Session] Failed to hydrate workspaces:", error);
}); }
})();
return () => { return () => {
cancelled = true; cancelled = true;
@@ -4,6 +4,7 @@ import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import type { WorkspaceDescriptor } from '@/stores/session-store' import type { WorkspaceDescriptor } from '@/stores/session-store'
import { projectDisplayNameFromProjectId } from '@/utils/project-display-name' import { projectDisplayNameFromProjectId } from '@/utils/project-display-name'
import { normalizeWorkspaceIdentity } from '@/utils/workspace-identity'
const EMPTY_ORDER: string[] = [] const EMPTY_ORDER: string[] = []
const EMPTY_PROJECTS: SidebarProjectEntry[] = [] const EMPTY_PROJECTS: SidebarProjectEntry[] = []
@@ -229,7 +230,7 @@ function toWorkspaceDescriptor(payload: {
}): WorkspaceDescriptor { }): WorkspaceDescriptor {
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null const activityAt = payload.activityAt ? new Date(payload.activityAt) : null
return { return {
id: payload.id, id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
projectId: payload.projectId, projectId: payload.projectId,
name: payload.name, name: payload.name,
status: payload.status, status: payload.status,
@@ -303,19 +304,31 @@ export function useSidebarWorkspacesList(options?: {
if (!client) { if (!client) {
return return
} }
void client void (async () => {
.fetchWorkspaces({ page: { limit: 200 } }) const next = new Map<string, WorkspaceDescriptor>()
.then((payload) => { let cursor: string | null = null
const next = new Map<string, WorkspaceDescriptor>() try {
for (const entry of payload.entries) { while (true) {
const workspace = toWorkspaceDescriptor(entry) const payload = await client.fetchWorkspaces({
next.set(workspace.id, workspace) sort: [{ key: 'activity_at', direction: 'desc' }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
})
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry)
next.set(workspace.id, workspace)
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break
}
cursor = payload.pageInfo.nextCursor
} }
const store = useSessionStore.getState() const store = useSessionStore.getState()
store.setWorkspaces(serverId, next) store.setWorkspaces(serverId, next)
store.setHasHydratedWorkspaces(serverId, true) store.setHasHydratedWorkspaces(serverId, true)
}) } catch {
.catch(() => undefined) // ignore explicit refresh failures; hook keeps existing data
}
})()
}, [connectionStatus, enabled, isActive, runtime, serverId]) }, [connectionStatus, enabled, isActive, runtime, serverId])
const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces
+242 -19
View File
@@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest";
import type { import type {
DaemonClient, DaemonClient,
ConnectionState, ConnectionState,
FetchAgentsEntry,
FetchAgentsOptions, FetchAgentsOptions,
} from "@server/client/daemon-client"; } from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
import { import {
HostRuntimeController, HostRuntimeController,
HostRuntimeStore, HostRuntimeStore,
@@ -19,6 +21,7 @@ class FakeDaemonClient {
public closeCalls = 0; public closeCalls = 0;
public ensureConnectedCalls = 0; public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = [];
public fetchAgentsResponses: Array<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> = [];
async connect(): Promise<void> { async connect(): Promise<void> {
this.connectCalls += 1; this.connectCalls += 1;
@@ -57,27 +60,16 @@ class FakeDaemonClient {
async fetchAgents( async fetchAgents(
options?: FetchAgentsOptions options?: FetchAgentsOptions
): Promise<{ ): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
entries: [];
pageInfo: {
hasMoreBefore: false;
hasMoreAfter: false;
beforeCursor: null;
afterCursor: null;
};
subscriptionId: string | null;
}> {
this.fetchAgentsCalls.push(options ?? {}); this.fetchAgentsCalls.push(options ?? {});
return { const queued = this.fetchAgentsResponses.shift();
if (queued) {
return queued;
}
return makeFetchAgentsPayload({
entries: [], entries: [],
pageInfo: { subscriptionId: options?.subscribe?.subscriptionId ?? undefined,
hasMoreBefore: false, });
hasMoreAfter: false,
beforeCursor: null,
afterCursor: null,
},
subscriptionId: options?.subscribe?.subscriptionId ?? null,
};
} }
setConnectionState(next: ConnectionState): void { setConnectionState(next: ConnectionState): void {
@@ -91,6 +83,83 @@ class FakeDaemonClient {
} }
} }
function makeFetchAgentsPayload(input: {
entries: FetchAgentsEntry[];
hasMore?: boolean;
nextCursor?: string | null;
subscriptionId?: string;
}): Awaited<ReturnType<DaemonClient["fetchAgents"]>> {
return {
entries: input.entries,
pageInfo: {
nextCursor: input.nextCursor ?? null,
prevCursor: null,
hasMore: input.hasMore ?? false,
} as Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"],
...(input.subscriptionId ? { subscriptionId: input.subscriptionId } : {}),
requestId: "req_test",
};
}
function makeFetchAgentsEntry(input: {
id: string;
cwd: string;
updatedAt: string;
title?: string | null;
requiresAttention?: boolean;
attentionReason?: "permission" | "error" | null;
}): FetchAgentsEntry {
return {
agent: {
id: input.id,
provider: "codex",
status: "idle",
createdAt: input.updatedAt,
updatedAt: input.updatedAt,
lastUserMessageAt: null,
lastError: undefined,
runtimeInfo: {
provider: "codex",
sessionId: null,
},
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: input.title ?? null,
cwd: input.cwd,
model: null,
thinkingOptionId: null,
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp:
input.requiresAttention && input.attentionReason ? input.updatedAt : null,
archivedAt: null,
labels: {},
},
project: {
projectKey: input.cwd,
projectName: "workspace",
checkout: {
cwd: input.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
};
}
function makeHost(input?: Partial<HostProfile>): HostProfile { function makeHost(input?: Partial<HostProfile>): HostProfile {
const direct: HostConnection = { const direct: HostConnection = {
id: "direct:lan:6767", id: "direct:lan:6767",
@@ -734,6 +803,11 @@ describe("HostRuntimeStore", () => {
}, },
}); });
useSessionStore.getState().initializeSession(
host.serverId,
fakeClient as unknown as DaemonClient,
null as any
);
store.syncHosts([host]); store.syncHosts([host]);
const timeoutAt = Date.now() + 200; const timeoutAt = Date.now() + 200;
@@ -744,6 +818,7 @@ describe("HostRuntimeStore", () => {
expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({ expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true }, filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_test" }, subscribe: { subscriptionId: "app:srv_test" },
page: { limit: 200 }, page: { limit: 200 },
}); });
@@ -753,6 +828,154 @@ describe("HostRuntimeStore", () => {
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
store.syncHosts([]); store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("defers directory bootstrap until session store is initialized for that server", async () => {
const host = makeHost({
serverId: "srv_deferred",
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
getClientId: async () => "cid_test_runtime",
},
});
store.syncHosts([host]);
await new Promise((resolve) => setTimeout(resolve, 250));
expect(fakeClient.fetchAgentsCalls).toHaveLength(0);
useSessionStore.getState().initializeSession(
host.serverId,
fakeClient as unknown as DaemonClient,
null as any
);
const timeoutAt = Date.now() + 600;
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(1);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_deferred" },
page: { limit: 200 },
});
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
const host = makeHost({
serverId: "srv_paged",
connections: [
{
id: "direct:lan:6767",
type: "direct",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-recent",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-04T12:00:00.000Z",
title: "Recent agent",
}),
],
hasMore: true,
nextCursor: "cursor-page-2",
subscriptionId: "app:srv_paged",
}),
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-stale-attention",
cwd: "/Users/moboudra/dev/paseo-pr67-review",
updatedAt: "2026-02-20T08:00:00.000Z",
title: "Needs triage",
requiresAttention: true,
attentionReason: "error",
}),
],
hasMore: false,
})
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
measureLatency: async () => 5,
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore.getState().initializeSession(
host.serverId,
fakeClient as unknown as DaemonClient,
null as any
);
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
while (
fakeClient.fetchAgentsCalls.length < 2 &&
Date.now() < timeoutAt
) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(fakeClient.fetchAgentsCalls).toHaveLength(2);
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: "app:srv_paged" },
page: { limit: 200 },
});
expect(fakeClient.fetchAgentsCalls[1]).toEqual({
filter: { includeArchived: true },
sort: [{ key: "updated_at", direction: "desc" }],
page: { limit: 200, cursor: "cursor-page-2" },
});
let staleAgent =
useSessionStore.getState().sessions[host.serverId]?.agents?.get(
"agent-stale-attention"
) ?? null;
const staleTimeoutAt = Date.now() + 300;
while (!staleAgent && Date.now() < staleTimeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
staleAgent =
useSessionStore.getState().sessions[host.serverId]?.agents?.get(
"agent-stale-attention"
) ?? null;
}
expect(staleAgent?.requiresAttention).toBe(true);
expect(staleAgent?.attentionReason).toBe("error");
const snapshot = store.getSnapshot(host.serverId);
expect(snapshot?.agentDirectoryStatus).toBe("ready");
expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
}); });
it("surfaces startup failures as error instead of leaving host idle", async () => { it("surfaces startup failures as error instead of leaving host idle", async () => {
+121 -23
View File
@@ -19,7 +19,7 @@ import {
} from "@/utils/connection-selection"; } from "@/utils/connection-selection";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { import {
recordDaemonClientDiagnostics, recordDaemonClientDiagnostics,
recordHostRuntimeCreateClient, recordHostRuntimeCreateClient,
@@ -140,6 +140,43 @@ export type HostRuntimeStartOptions = {
const PROBE_INTERVAL_MS = 10_000; const PROBE_INTERVAL_MS = 10_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
const AGENT_DIRECTORY_SESSION_RETRY_MS = 150;
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
];
function readFetchAgentsHasMore(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"]
): boolean {
const page = pageInfo as {
hasMore?: boolean;
hasMoreAfter?: boolean;
};
if (typeof page.hasMore === "boolean") {
return page.hasMore;
}
if (typeof page.hasMoreAfter === "boolean") {
return page.hasMoreAfter;
}
return false;
}
function readFetchAgentsNextCursor(
pageInfo: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["pageInfo"]
): string | null {
const page = pageInfo as {
nextCursor?: string | null;
afterCursor?: string | null;
};
if (typeof page.nextCursor === "string" && page.nextCursor.length > 0) {
return page.nextCursor;
}
if (typeof page.afterCursor === "string" && page.afterCursor.length > 0) {
return page.afterCursor;
}
return null;
}
function toActiveConnection(connection: HostConnection): ActiveConnection { function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "direct") { if (connection.type === "direct") {
@@ -944,6 +981,10 @@ export class HostRuntimeStore {
private deps: HostRuntimeControllerDeps; private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>(); private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>(); private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private agentDirectorySessionRetryTimerByServer = new Map<
string,
ReturnType<typeof setTimeout>
>();
constructor(input?: { constructor(input?: {
deps?: HostRuntimeControllerDeps; deps?: HostRuntimeControllerDeps;
@@ -960,6 +1001,7 @@ export class HostRuntimeStore {
this.controllers.delete(serverId); this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId); this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId); this.agentDirectoryBootstrapInFlight.delete(serverId);
this.clearAgentDirectorySessionRetry(serverId);
void controller.stop(); void controller.stop();
this.emit(serverId); this.emit(serverId);
} }
@@ -1005,23 +1047,32 @@ export class HostRuntimeStore {
useSessionStore.getState().bumpHistorySyncGeneration(serverId); useSessionStore.getState().bumpHistorySyncGeneration(serverId);
} }
// Bootstrap once per reconnect/online transition. Runtime owns this policy, // Runtime owns directory bootstrap policy, including reconnect and delayed
// not React effects in session components. // session initialization races.
if (snapshot.connectionStatus !== "online") { if (
return; snapshot.connectionStatus !== "online" ||
} snapshot.hasEverLoadedAgentDirectory
if (previousStatus === "online") { ) {
this.clearAgentDirectorySessionRetry(serverId);
return; return;
} }
if (this.agentDirectoryBootstrapInFlight.has(serverId)) { if (this.agentDirectoryBootstrapInFlight.has(serverId)) {
return; return;
} }
if (!useSessionStore.getState().sessions[serverId]) {
this.scheduleAgentDirectorySessionRetry(serverId);
return;
}
this.clearAgentDirectorySessionRetry(serverId);
const bootstrap = this.refreshAgentDirectory({ const bootstrap = Promise.resolve()
serverId, .then(() =>
subscribe: { subscriptionId: `app:${serverId}` }, this.refreshAgentDirectory({
page: { limit: 200 }, serverId,
}) subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
})
)
.then(() => undefined) .then(() => undefined)
.catch((error) => { .catch((error) => {
console.error("[HostRuntime] agent directory bootstrap failed", { console.error("[HostRuntime] agent directory bootstrap failed", {
@@ -1039,6 +1090,26 @@ export class HostRuntimeStore {
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
} }
private scheduleAgentDirectorySessionRetry(serverId: string): void {
if (this.agentDirectorySessionRetryTimerByServer.has(serverId)) {
return;
}
const handle = setTimeout(() => {
this.agentDirectorySessionRetryTimerByServer.delete(serverId);
this.maybeAutoBootstrapAgentDirectory(serverId);
}, AGENT_DIRECTORY_SESSION_RETRY_MS);
this.agentDirectorySessionRetryTimerByServer.set(serverId, handle);
}
private clearAgentDirectorySessionRetry(serverId: string): void {
const handle = this.agentDirectorySessionRetryTimerByServer.get(serverId);
if (!handle) {
return;
}
clearTimeout(handle);
this.agentDirectorySessionRetryTimerByServer.delete(serverId);
}
getSnapshot(serverId: string): HostRuntimeSnapshot | null { getSnapshot(serverId: string): HostRuntimeSnapshot | null {
return this.controllers.get(serverId)?.getSnapshot() ?? null; return this.controllers.get(serverId)?.getSnapshot() ?? null;
} }
@@ -1112,19 +1183,46 @@ export class HostRuntimeStore {
controller.markAgentDirectorySyncLoading(); controller.markAgentDirectorySyncLoading();
try { try {
const payload = await client.fetchAgents({ const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT;
filter: input.filter ?? { includeArchived: true }, let cursor = input.page?.cursor ?? null;
...(input.subscribe ? { subscribe: input.subscribe } : {}), let includeSubscribe = true;
...(input.page ? { page: input.page } : {}), let subscriptionId: string | null = null;
}); const allAgents = new Map<string, Agent>();
const agents = applyFetchedAgentDirectory({
serverId: input.serverId, while (true) {
entries: payload.entries, const payload = await client.fetchAgents({
}).agents; filter: input.filter ?? { includeArchived: true },
sort: DEFAULT_AGENT_DIRECTORY_SORT,
...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}),
page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit },
});
const pageAgents = applyFetchedAgentDirectory({
serverId: input.serverId,
entries: payload.entries,
}).agents;
for (const [agentId, agent] of pageAgents) {
allAgents.set(agentId, agent);
}
subscriptionId = subscriptionId ?? payload.subscriptionId ?? null;
includeSubscribe = false;
if (!readFetchAgentsHasMore(payload.pageInfo)) {
break;
}
const nextCursor = readFetchAgentsNextCursor(payload.pageInfo);
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
controller.markAgentDirectorySyncReady(); controller.markAgentDirectorySyncReady();
return { return {
agents, agents: allAgents,
subscriptionId: payload.subscriptionId ?? null, subscriptionId,
}; };
} catch (error) { } catch (error) {
controller.markAgentDirectorySyncError(toErrorMessage(error)); controller.markAgentDirectorySyncError(toErrorMessage(error));
@@ -134,4 +134,24 @@ describe("workspace agent visibility", () => {
"older-activity", "older-activity",
]); ]);
}); });
it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => {
const sessionAgents = new Map<string, Agent>([
[
"slash-agent",
makeAgent({
id: "slash-agent",
cwd: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid/",
}),
],
]);
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
});
expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["slash-agent"]);
expect(result.lookupById.has("slash-agent")).toBe(true);
});
}); });
@@ -1,4 +1,5 @@
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] {
const sorted = [...agents]; const sorted = [...agents];
@@ -20,6 +21,10 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
function normalizeWorkspaceId(value: string | null | undefined): string {
return normalizeWorkspaceIdentity(value) ?? "";
}
export function deriveWorkspaceAgentVisibility(input: { export function deriveWorkspaceAgentVisibility(input: {
sessionAgents: Map<string, Agent> | undefined; sessionAgents: Map<string, Agent> | undefined;
workspaceId: string; workspaceId: string;
@@ -28,6 +33,7 @@ export function deriveWorkspaceAgentVisibility(input: {
lookupById: Map<string, Agent>; lookupById: Map<string, Agent>;
} { } {
const { sessionAgents, workspaceId } = input; const { sessionAgents, workspaceId } = input;
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
if (!sessionAgents || !workspaceId) { if (!sessionAgents || !workspaceId) {
return { return {
visibleAgents: [], visibleAgents: [],
@@ -38,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
const lookupById = new Map<string, Agent>(); const lookupById = new Map<string, Agent>();
const visible: Agent[] = []; const visible: Agent[] = [];
for (const agent of sessionAgents.values()) { for (const agent of sessionAgents.values()) {
if ((trimNonEmpty(agent.cwd) ?? "") !== workspaceId) { if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
continue; continue;
} }
lookupById.set(agent.id, agent); lookupById.set(agent.id, agent);
@@ -54,6 +54,7 @@ import {
buildHostWorkspaceTabRoute, buildHostWorkspaceTabRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { import {
checkoutStatusQueryKey, checkoutStatusQueryKey,
@@ -139,7 +140,8 @@ function WorkspaceScreenContent({
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
const normalizedWorkspaceId = decodeWorkspaceIdFromPathSegment(workspaceId) ?? ""; const normalizedWorkspaceId =
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(normalizedServerId); const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
@@ -298,6 +300,9 @@ function WorkspaceScreenContent({
const hasHydratedWorkspaces = useSessionStore( const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false
); );
const hasHydratedAgents = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false
);
const workspaceHeader = workspaceDescriptor const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) ? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null; : null;
@@ -916,15 +921,20 @@ function WorkspaceScreenContent({
) { ) {
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyStateText}> <ActivityIndicator color={theme.colors.foregroundMuted} />
Workspace descriptor missing for this route.
</Text>
</View> </View>
); );
} }
const target = activeTab?.target ?? null; const target = activeTab?.target ?? null;
if (!target) { if (!target) {
if (!hasHydratedAgents) {
return (
<View style={styles.emptyState}>
<ActivityIndicator color={theme.colors.foregroundMuted} />
</View>
);
}
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyStateText}> <Text style={styles.emptyStateText}>
@@ -161,4 +161,40 @@ describe("deriveWorkspaceTabModel", () => {
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(true); expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(true);
}); });
it("includes older attention and failure agents in workspace tabs when session data is complete", () => {
const olderPermissionAgent = makeAgent({
id: "agent-permission-old",
title: "Need permission",
createdAt: new Date("2026-01-15T00:00:00.000Z"),
requiresAttention: true,
attentionReason: "permission",
});
const olderFailedAgent = makeAgent({
id: "agent-failed-old",
title: "Failed run",
createdAt: new Date("2026-01-10T00:00:00.000Z"),
requiresAttention: true,
attentionReason: "error",
});
const newerAgent = makeAgent({
id: "agent-recent",
title: "Recent work",
createdAt: new Date("2026-03-04T00:00:00.000Z"),
});
const model = deriveWorkspaceTabModel({
workspaceAgents: [newerAgent, olderPermissionAgent, olderFailedAgent],
terminals: [],
uiTabs: [],
tabOrder: [],
});
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-permission-old")).toBe(
true
);
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-failed-old")).toBe(
true
);
});
}); });
@@ -56,6 +56,12 @@ describe("workspace route parsing", () => {
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo"); expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
}); });
it("decodes non-canonical base64url workspace IDs used by older links", () => {
expect(
decodeWorkspaceIdFromPathSegment("L1VzZXJzL21vYm91ZHJhL2Rldi9wYXNlby")
).toBe("/Users/moboudra/dev/paseo");
});
it("encodes file paths as base64url (no padding)", () => { it("encodes file paths as base64url (no padding)", () => {
const encoded = encodeFilePathForPathSegment("src/index.ts"); const encoded = encodeFilePathForPathSegment("src/index.ts");
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
+26 -1
View File
@@ -30,7 +30,7 @@ function toBase64UrlNoPad(input: string): string {
.replace(/=+$/g, ""); .replace(/=+$/g, "");
} }
function tryDecodeBase64UrlNoPadUtf8(input: string): string | null { function decodeBase64UrlNoPadUtf8(input: string): string | null {
const normalized = input.trim(); const normalized = input.trim();
if (normalized.length === 0) { if (normalized.length === 0) {
return null; return null;
@@ -49,6 +49,16 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
return null; return null;
} }
return decoded;
}
function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
const normalized = input.trim();
const decoded = decodeBase64UrlNoPadUtf8(normalized);
if (!decoded) {
return null;
}
// Validate via round-trip to avoid false positives ("workspace-1" etc). // Validate via round-trip to avoid false positives ("workspace-1" etc).
if (toBase64UrlNoPad(decoded) !== normalized) { if (toBase64UrlNoPad(decoded) !== normalized) {
return null; return null;
@@ -57,6 +67,14 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
return decoded; return decoded;
} }
function isPathLikeWorkspaceIdentity(value: string): boolean {
return (
value.includes("/") ||
value.includes("\\") ||
/^[A-Za-z]:[\\/]/.test(value)
);
}
function normalizeWorkspaceId(value: string): string { function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
} }
@@ -106,6 +124,13 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return normalizeWorkspaceId(base64Decoded); return normalizeWorkspaceId(base64Decoded);
} }
// Some older links use non-canonical base64url (non-zero pad bits). Accept
// decoded values only when they clearly represent filesystem paths.
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
if (relaxedBase64Decoded && isPathLikeWorkspaceIdentity(relaxedBase64Decoded)) {
return normalizeWorkspaceId(relaxedBase64Decoded);
}
return normalizeWorkspaceId(decoded); return normalizeWorkspaceId(decoded);
} }
@@ -0,0 +1,20 @@
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeWorkspaceIdentity(value: string | null | undefined): string | null {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return null;
}
const withUnixSeparators = trimmed.replace(/\\/g, "/");
if (withUnixSeparators === "/") {
return withUnixSeparators;
}
const withoutTrailingSlash = withUnixSeparators.replace(/\/+$/, "");
return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : "/";
}