From 4f1783fc76f1e48611c422aa6931d9f11152a60e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 12:25:14 +0700 Subject: [PATCH] refactor: improve drag interaction and workspace identity normalization --- .../src/components/sidebar-workspace-list.tsx | 276 +++++++++++++----- packages/app/src/contexts/session-context.tsx | 48 ++- .../src/hooks/use-sidebar-workspaces-list.ts | 33 ++- packages/app/src/runtime/host-runtime.test.ts | 261 +++++++++++++++-- packages/app/src/runtime/host-runtime.ts | 144 +++++++-- .../workspace-agent-visibility.test.ts | 20 ++ .../workspace/workspace-agent-visibility.ts | 8 +- .../screens/workspace/workspace-screen.tsx | 18 +- .../workspace/workspace-tab-model.test.ts | 36 +++ packages/app/src/utils/host-routes.test.ts | 6 + packages/app/src/utils/host-routes.ts | 27 +- packages/app/src/utils/workspace-identity.ts | 20 ++ 12 files changed, 750 insertions(+), 147 deletions(-) create mode 100644 packages/app/src/utils/workspace-identity.ts diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c0842534e..90ababd88 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -6,6 +6,7 @@ import { Platform, ActivityIndicator, Alert, + StatusBar, ScrollView, type GestureResponderEvent, } from 'react-native' @@ -23,7 +24,7 @@ import { import { router, usePathname, useSegments } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' -import { ChevronDown, ChevronRight, GripVertical } from 'lucide-react-native' +import { ChevronDown, ChevronRight } from 'lucide-react-native' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' @@ -154,33 +155,208 @@ function WorkspaceStatusIndicator({ function useLongPressDragInteraction(input: { drag: () => void + menuController: ReturnType | null + debugId: string }) { 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 | null>(null) + const contextMenuTimerRef = useRef | 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(() => { - console.log('[sidebar-dnd-debug] long-press armed') - void Haptics.selectionAsync().catch(() => {}) - didLongPressRef.current = true - }, []) + // Manual timers own long-press behavior on mobile. + console.log('[sidebar-dnd-debug] native onLongPress ignored (manual state machine active)', { + 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) => { 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', { + id: input.debugId, x: event.nativeEvent.pageX, 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(() => { + clearTimers() console.log('[sidebar-dnd-debug] press-out no context-menu', { + id: input.debugId, 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 { didLongPressRef, handleLongPress, handlePressIn, + handleTouchMove, handlePressOut, } } @@ -195,8 +371,11 @@ function ProjectHeaderRow({ isDragging, dragHandleProps, }: ProjectHeaderRowProps) { + const menuController = useContextMenu() const interaction = useLongPressDragInteraction({ drag, + menuController, + debugId: `project:${project.projectKey}`, }) const handlePress = useCallback(() => { @@ -207,15 +386,9 @@ function ProjectHeaderRow({ 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 = ( [ styles.projectRow, isDragging && styles.projectRowDragging, @@ -223,13 +396,17 @@ function ProjectHeaderRow({ pressed && styles.projectRowPressed, ]} onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - onLongPress={interaction.handleLongPress} - delayLongPress={350} testID={`sidebar-project-row-${project.projectKey}`} > - + {collapsed ? ( ) : ( @@ -248,20 +425,6 @@ function ProjectHeaderRow({ {displayName} - (event as any).stopPropagation?.()} - style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]} - testID={`sidebar-project-drag-handle-${project.projectKey}`} - > - - ) @@ -300,6 +463,8 @@ function WorkspaceRowInner({ const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const interaction = useLongPressDragInteraction({ drag, + menuController, + debugId: `workspace:${workspace.workspaceKey}`, }) const handlePress = useCallback(() => { @@ -310,17 +475,12 @@ function WorkspaceRowInner({ 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 = ( <> @@ -339,27 +499,13 @@ function WorkspaceRowInner({ {shortcutNumber} ) : null} - (event as any).stopPropagation?.()} - style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]} - testID={`sidebar-workspace-drag-handle-${workspace.workspaceKey}`} - > - - ) const trigger = menuController ? ( [ styles.workspaceRow, @@ -369,10 +515,9 @@ function WorkspaceRowInner({ pressed && styles.workspaceRowPressed, ]} onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - onLongPress={interaction.handleLongPress} - delayLongPress={350} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > {rowChildren} @@ -388,10 +533,9 @@ function WorkspaceRowInner({ pressed && styles.workspaceRowPressed, ]} onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - onLongPress={interaction.handleLongPress} - delayLongPress={350} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > {rowChildren} @@ -1191,22 +1335,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, 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: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index cb364e6c7..1510215ea 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -49,6 +49,7 @@ import { normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; @@ -132,7 +133,7 @@ function normalizeWorkspaceDescriptor( ? new Date(payload.activityAt) : null; return { - id: payload.id, + id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, projectId: payload.projectId, name: payload.name, status: payload.status, @@ -448,26 +449,43 @@ function SessionProviderInternal({ } let cancelled = false; - void client - .fetchWorkspaces({ - subscribe: {}, - page: { limit: 200 }, - }) - .then((payload) => { + void (async () => { + const workspaces = new Map(); + let cursor: string | null = null; + let includeSubscribe = true; + + 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) { return; } - const workspaces = new Map(); - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - workspaces.set(workspace.id, workspace); - } setWorkspaces(serverId, workspaces); setHasHydratedWorkspaces(serverId, true); - }) - .catch((error) => { + } catch (error) { console.error("[Session] Failed to hydrate workspaces:", error); - }); + } + })(); return () => { cancelled = true; diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index dfeae31c9..1a9fbedaf 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -4,6 +4,7 @@ import { getHostRuntimeStore } from '@/runtime/host-runtime' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import type { WorkspaceDescriptor } from '@/stores/session-store' import { projectDisplayNameFromProjectId } from '@/utils/project-display-name' +import { normalizeWorkspaceIdentity } from '@/utils/workspace-identity' const EMPTY_ORDER: string[] = [] const EMPTY_PROJECTS: SidebarProjectEntry[] = [] @@ -229,7 +230,7 @@ function toWorkspaceDescriptor(payload: { }): WorkspaceDescriptor { const activityAt = payload.activityAt ? new Date(payload.activityAt) : null return { - id: payload.id, + id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, projectId: payload.projectId, name: payload.name, status: payload.status, @@ -303,19 +304,31 @@ export function useSidebarWorkspacesList(options?: { if (!client) { return } - void client - .fetchWorkspaces({ page: { limit: 200 } }) - .then((payload) => { - const next = new Map() - for (const entry of payload.entries) { - const workspace = toWorkspaceDescriptor(entry) - next.set(workspace.id, workspace) + void (async () => { + const next = new Map() + let cursor: string | null = null + try { + while (true) { + const payload = await client.fetchWorkspaces({ + 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() store.setWorkspaces(serverId, next) store.setHasHydratedWorkspaces(serverId, true) - }) - .catch(() => undefined) + } catch { + // ignore explicit refresh failures; hook keeps existing data + } + })() }, [connectionStatus, enabled, isActive, runtime, serverId]) const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 4d032eaea..4daf96b8c 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest"; import type { DaemonClient, ConnectionState, + FetchAgentsEntry, FetchAgentsOptions, } from "@server/client/daemon-client"; import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; +import { useSessionStore } from "@/stores/session-store"; import { HostRuntimeController, HostRuntimeStore, @@ -19,6 +21,7 @@ class FakeDaemonClient { public closeCalls = 0; public ensureConnectedCalls = 0; public fetchAgentsCalls: FetchAgentsOptions[] = []; + public fetchAgentsResponses: Array>> = []; async connect(): Promise { this.connectCalls += 1; @@ -57,27 +60,16 @@ class FakeDaemonClient { async fetchAgents( options?: FetchAgentsOptions - ): Promise<{ - entries: []; - pageInfo: { - hasMoreBefore: false; - hasMoreAfter: false; - beforeCursor: null; - afterCursor: null; - }; - subscriptionId: string | null; - }> { + ): Promise>> { this.fetchAgentsCalls.push(options ?? {}); - return { + const queued = this.fetchAgentsResponses.shift(); + if (queued) { + return queued; + } + return makeFetchAgentsPayload({ entries: [], - pageInfo: { - hasMoreBefore: false, - hasMoreAfter: false, - beforeCursor: null, - afterCursor: null, - }, - subscriptionId: options?.subscribe?.subscriptionId ?? null, - }; + subscriptionId: options?.subscribe?.subscriptionId ?? undefined, + }); } setConnectionState(next: ConnectionState): void { @@ -91,6 +83,83 @@ class FakeDaemonClient { } } +function makeFetchAgentsPayload(input: { + entries: FetchAgentsEntry[]; + hasMore?: boolean; + nextCursor?: string | null; + subscriptionId?: string; +}): Awaited> { + return { + entries: input.entries, + pageInfo: { + nextCursor: input.nextCursor ?? null, + prevCursor: null, + hasMore: input.hasMore ?? false, + } as Awaited>["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 { const direct: HostConnection = { 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]); const timeoutAt = Date.now() + 200; @@ -744,6 +818,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ filter: { includeArchived: true }, + sort: [{ key: "updated_at", direction: "desc" }], subscribe: { subscriptionId: "app:srv_test" }, page: { limit: 200 }, }); @@ -753,6 +828,154 @@ describe("HostRuntimeStore", () => { expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); 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 () => { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index af669b9a6..403c7911f 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -19,7 +19,7 @@ import { } from "@/utils/connection-selection"; import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; -import { useSessionStore } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { recordDaemonClientDiagnostics, recordHostRuntimeCreateClient, @@ -140,6 +140,43 @@ export type HostRuntimeStartOptions = { const PROBE_INTERVAL_MS = 10_000; const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; 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 = [ + { key: "updated_at", direction: "desc" }, +]; + +function readFetchAgentsHasMore( + pageInfo: Awaited>["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>["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 { if (connection.type === "direct") { @@ -944,6 +981,10 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); + private agentDirectorySessionRetryTimerByServer = new Map< + string, + ReturnType + >(); constructor(input?: { deps?: HostRuntimeControllerDeps; @@ -960,6 +1001,7 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.agentDirectoryBootstrapInFlight.delete(serverId); + this.clearAgentDirectorySessionRetry(serverId); void controller.stop(); this.emit(serverId); } @@ -1005,23 +1047,32 @@ export class HostRuntimeStore { useSessionStore.getState().bumpHistorySyncGeneration(serverId); } - // Bootstrap once per reconnect/online transition. Runtime owns this policy, - // not React effects in session components. - if (snapshot.connectionStatus !== "online") { - return; - } - if (previousStatus === "online") { + // Runtime owns directory bootstrap policy, including reconnect and delayed + // session initialization races. + if ( + snapshot.connectionStatus !== "online" || + snapshot.hasEverLoadedAgentDirectory + ) { + this.clearAgentDirectorySessionRetry(serverId); return; } if (this.agentDirectoryBootstrapInFlight.has(serverId)) { return; } + if (!useSessionStore.getState().sessions[serverId]) { + this.scheduleAgentDirectorySessionRetry(serverId); + return; + } + this.clearAgentDirectorySessionRetry(serverId); - const bootstrap = this.refreshAgentDirectory({ - serverId, - subscribe: { subscriptionId: `app:${serverId}` }, - page: { limit: 200 }, - }) + const bootstrap = Promise.resolve() + .then(() => + this.refreshAgentDirectory({ + serverId, + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, + }) + ) .then(() => undefined) .catch((error) => { console.error("[HostRuntime] agent directory bootstrap failed", { @@ -1039,6 +1090,26 @@ export class HostRuntimeStore { 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 { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -1112,19 +1183,46 @@ export class HostRuntimeStore { controller.markAgentDirectorySyncLoading(); try { - const payload = await client.fetchAgents({ - filter: input.filter ?? { includeArchived: true }, - ...(input.subscribe ? { subscribe: input.subscribe } : {}), - ...(input.page ? { page: input.page } : {}), - }); - const agents = applyFetchedAgentDirectory({ - serverId: input.serverId, - entries: payload.entries, - }).agents; + const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT; + let cursor = input.page?.cursor ?? null; + let includeSubscribe = true; + let subscriptionId: string | null = null; + const allAgents = new Map(); + + while (true) { + const payload = await client.fetchAgents({ + 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(); return { - agents, - subscriptionId: payload.subscriptionId ?? null, + agents: allAgents, + subscriptionId, }; } catch (error) { controller.markAgentDirectorySyncError(toErrorMessage(error)); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 5ae2b2143..a13b6d0b4 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -134,4 +134,24 @@ describe("workspace agent visibility", () => { "older-activity", ]); }); + + it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => { + const sessionAgents = new Map([ + [ + "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); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index c2dbdec3a..65ff212f2 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -1,4 +1,5 @@ import type { Agent } from "@/stores/session-store"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { const sorted = [...agents]; @@ -20,6 +21,10 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeWorkspaceId(value: string | null | undefined): string { + return normalizeWorkspaceIdentity(value) ?? ""; +} + export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; workspaceId: string; @@ -28,6 +33,7 @@ export function deriveWorkspaceAgentVisibility(input: { lookupById: Map; } { const { sessionAgents, workspaceId } = input; + const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId); if (!sessionAgents || !workspaceId) { return { visibleAgents: [], @@ -38,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { const lookupById = new Map(); const visible: Agent[] = []; for (const agent of sessionAgents.values()) { - if ((trimNonEmpty(agent.cwd) ?? "") !== workspaceId) { + if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) { continue; } lookupById.set(agent.id, agent); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 37b9c2498..2d8a60b2c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -54,6 +54,7 @@ import { buildHostWorkspaceTabRoute, decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { checkoutStatusQueryKey, @@ -139,7 +140,8 @@ function WorkspaceScreenContent({ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; - const normalizedWorkspaceId = decodeWorkspaceIdFromPathSegment(workspaceId) ?? ""; + const normalizedWorkspaceId = + normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? ""; const queryClient = useQueryClient(); const { client, isConnected } = useHostRuntimeSession(normalizedServerId); @@ -298,6 +300,9 @@ function WorkspaceScreenContent({ const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false ); + const hasHydratedAgents = useSessionStore( + (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false + ); const workspaceHeader = workspaceDescriptor ? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) : null; @@ -916,15 +921,20 @@ function WorkspaceScreenContent({ ) { return ( - - Workspace descriptor missing for this route. - + ); } const target = activeTab?.target ?? null; if (!target) { + if (!hasHydratedAgents) { + return ( + + + + ); + } return ( diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 2c7160023..c29191a5a 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -161,4 +161,40 @@ describe("deriveWorkspaceTabModel", () => { 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 + ); + }); }); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 882aac0a4..6f824cdc1 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -56,6 +56,12 @@ describe("workspace route parsing", () => { 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)", () => { const encoded = encodeFilePathForPathSegment("src/index.ts"); expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 76929309c..ba430dbd4 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -30,7 +30,7 @@ function toBase64UrlNoPad(input: string): string { .replace(/=+$/g, ""); } -function tryDecodeBase64UrlNoPadUtf8(input: string): string | null { +function decodeBase64UrlNoPadUtf8(input: string): string | null { const normalized = input.trim(); if (normalized.length === 0) { return null; @@ -49,6 +49,16 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | 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). if (toBase64UrlNoPad(decoded) !== normalized) { return null; @@ -57,6 +67,14 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null { return decoded; } +function isPathLikeWorkspaceIdentity(value: string): boolean { + return ( + value.includes("/") || + value.includes("\\") || + /^[A-Za-z]:[\\/]/.test(value) + ); +} + function normalizeWorkspaceId(value: string): string { return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); } @@ -106,6 +124,13 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st 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); } diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts new file mode 100644 index 000000000..150df4885 --- /dev/null +++ b/packages/app/src/utils/workspace-identity.ts @@ -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 : "/"; +}