refactor: improve drag interaction and workspace identity normalization
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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_-]+$/);
|
||||||
|
|||||||
@@ -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 : "/";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user