feat: add explicit drag handles to sidebar lists and refactor workspace tab model
This commit is contained in:
@@ -1,9 +1,6 @@
|
|||||||
import { RefreshControl } from "react-native";
|
import { RefreshControl } from "react-native";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import DraggableFlatList, {
|
import DraggableFlatList, { type RenderItemParams } from "react-native-draggable-flatlist";
|
||||||
NestableDraggableFlatList,
|
|
||||||
type RenderItemParams,
|
|
||||||
} from "react-native-draggable-flatlist";
|
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import type {
|
import type {
|
||||||
DraggableListProps,
|
DraggableListProps,
|
||||||
@@ -33,57 +30,63 @@ export function DraggableList<T>({
|
|||||||
simultaneousGestureRef,
|
simultaneousGestureRef,
|
||||||
waitFor,
|
waitFor,
|
||||||
onDragBegin: onDragBeginProp,
|
onDragBegin: onDragBeginProp,
|
||||||
nestable = false,
|
onDragIntent: onDragIntentProp,
|
||||||
|
onDragRelease: onDragReleaseProp,
|
||||||
|
nestable: _nestable = false,
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// Pass the ref directly to DraggableFlatList - it handles the gesture coordination
|
|
||||||
// The ref may not have .current set yet, but that's okay - DraggableFlatList will
|
|
||||||
// read it when the gesture is being recognized
|
|
||||||
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
|
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
|
||||||
|
|
||||||
const handleRenderItem = useCallback(
|
const handleRenderItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
|
const itemKey = keyExtractor(item, index);
|
||||||
|
const dragWithLog = () => {
|
||||||
|
console.log("[sidebar-dnd-debug] row drag() called", { testID, itemKey, index });
|
||||||
|
onDragIntentProp?.();
|
||||||
|
drag();
|
||||||
|
};
|
||||||
const info: DraggableRenderItemInfo<T> = {
|
const info: DraggableRenderItemInfo<T> = {
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
drag,
|
drag: dragWithLog,
|
||||||
isActive,
|
isActive,
|
||||||
};
|
};
|
||||||
return renderItem(info);
|
return renderItem(info);
|
||||||
},
|
},
|
||||||
[renderItem]
|
[keyExtractor, onDragIntentProp, renderItem, testID]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
({ data: newData }: { data: T[] }) => {
|
({ data: newData }: { data: T[] }) => {
|
||||||
|
console.log("[sidebar-dnd-debug] list onDragEnd", { testID, count: newData.length });
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onDragEnd(newData);
|
onDragEnd(newData);
|
||||||
},
|
},
|
||||||
[onDragEnd]
|
[onDragEnd, testID]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragBegin = useCallback(() => {
|
const handleDragBegin = useCallback(() => {
|
||||||
|
console.log("[sidebar-dnd-debug] list onDragBegin", { testID });
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
onDragBeginProp?.();
|
onDragBeginProp?.();
|
||||||
}, [onDragBeginProp]);
|
}, [onDragBeginProp, testID]);
|
||||||
|
|
||||||
const handleRelease = useCallback(() => {
|
const handleRelease = useCallback(() => {
|
||||||
|
console.log("[sidebar-dnd-debug] list onRelease", { testID });
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}, []);
|
onDragReleaseProp?.();
|
||||||
|
}, [onDragReleaseProp, testID]);
|
||||||
|
|
||||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||||
const resolvedContainerStyle =
|
const resolvedContainerStyle =
|
||||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||||
const ListComponent: typeof DraggableFlatList = (nestable
|
const shouldShowRefreshControl = showRefreshControl;
|
||||||
? (NestableDraggableFlatList as any)
|
|
||||||
: DraggableFlatList) as any;
|
|
||||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListComponent
|
<DraggableFlatList
|
||||||
testID={testID}
|
testID={testID}
|
||||||
data={data}
|
data={data}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -98,11 +101,10 @@ export function DraggableList<T>({
|
|||||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||||
scrollEnabled={scrollEnabled}
|
scrollEnabled={scrollEnabled}
|
||||||
simultaneousHandlers={simultaneousHandlers}
|
simultaneousHandlers={simultaneousHandlers}
|
||||||
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
|
activationDistance={6}
|
||||||
activationDistance={20}
|
|
||||||
onDragBegin={handleDragBegin}
|
onDragBegin={handleDragBegin}
|
||||||
onRelease={handleRelease}
|
onRelease={handleRelease}
|
||||||
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
|
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
|
||||||
waitFor={waitFor}
|
waitFor={waitFor}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
shouldShowRefreshControl ? (
|
shouldShowRefreshControl ? (
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export interface DraggableListProps<T> {
|
|||||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
||||||
/** Called when a drag gesture begins (before items are reordered) */
|
/** Called when a drag gesture begins (before items are reordered) */
|
||||||
onDragBegin?: () => void;
|
onDragBegin?: () => void;
|
||||||
|
/** Called immediately before invoking row `drag()` to lock outer owners. */
|
||||||
|
onDragIntent?: () => void;
|
||||||
|
/** Called when drag interaction ends (finger released). */
|
||||||
|
onDragRelease?: () => void;
|
||||||
/**
|
/**
|
||||||
* Native-only: use the nestable draggable-flatlist variant for nested drag
|
* Native-only: use the nestable draggable-flatlist variant for nested drag
|
||||||
* lists coordinated by a shared NestableScrollContainer.
|
* lists coordinated by a shared NestableScrollContainer.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
StatusBar,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import * as Haptics from 'expo-haptics'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -22,9 +22,8 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
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 { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
import { type GestureType } from 'react-native-gesture-handler'
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
import { ChevronDown, ChevronRight, GripVertical } from 'lucide-react-native'
|
||||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
|
||||||
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'
|
||||||
@@ -53,9 +52,6 @@ import { useToast } from '@/contexts/toast-context'
|
|||||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||||
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
|
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
|
||||||
import {
|
|
||||||
shouldOpenContextMenuOnPressOut,
|
|
||||||
} from '@/utils/sidebar-gesture-arbitration'
|
|
||||||
|
|
||||||
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
||||||
|
|
||||||
@@ -78,11 +74,6 @@ interface SidebarWorkspaceListProps {
|
|||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>
|
parentGestureRef?: MutableRefObject<GestureType | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContextMenuController {
|
|
||||||
setAnchorRect: (rect: { x: number; y: number; width: number; height: number } | null) => void
|
|
||||||
setOpen: (open: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectHeaderRowProps {
|
interface ProjectHeaderRowProps {
|
||||||
project: SidebarProjectEntry
|
project: SidebarProjectEntry
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -90,6 +81,7 @@ interface ProjectHeaderRowProps {
|
|||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +92,10 @@ interface WorkspaceRowInnerProps {
|
|||||||
showShortcutBadge: boolean
|
showShortcutBadge: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
isArchiving: boolean
|
isArchiving: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
menuController: ContextMenuController | null
|
menuController: ReturnType<typeof useContextMenu> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
||||||
@@ -161,159 +154,34 @@ function WorkspaceStatusIndicator({
|
|||||||
|
|
||||||
function useLongPressDragInteraction(input: {
|
function useLongPressDragInteraction(input: {
|
||||||
drag: () => void
|
drag: () => void
|
||||||
menuController: ContextMenuController | null
|
|
||||||
}) {
|
}) {
|
||||||
const didLongPressRef = useRef(false)
|
const didLongPressRef = useRef(false)
|
||||||
const didLongPressCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const longPressArmedRef = useRef(false)
|
|
||||||
const longPressCancelledRef = useRef(false)
|
|
||||||
const didStartDragRef = useRef(false)
|
|
||||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (!didLongPressCleanupTimerRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearTimeout(didLongPressCleanupTimerRef.current)
|
|
||||||
didLongPressCleanupTimerRef.current = null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const openContextMenuAtTouchStart = useCallback(() => {
|
|
||||||
if (!input.menuController) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const point = touchStartRef.current
|
|
||||||
if (!point) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
|
|
||||||
input.menuController.setAnchorRect({
|
|
||||||
x: point.x,
|
|
||||||
y: point.y + statusBarHeight,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
})
|
|
||||||
input.menuController.setOpen(true)
|
|
||||||
}, [input.menuController])
|
|
||||||
|
|
||||||
const handleLongPress = useCallback(() => {
|
const handleLongPress = useCallback(() => {
|
||||||
if (Platform.OS === 'web') {
|
console.log('[sidebar-dnd-debug] long-press armed')
|
||||||
return
|
void Haptics.selectionAsync().catch(() => {})
|
||||||
}
|
|
||||||
if (longPressCancelledRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
didLongPressRef.current = true
|
didLongPressRef.current = true
|
||||||
longPressArmedRef.current = true
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||||
if (didLongPressCleanupTimerRef.current) {
|
didLongPressRef.current = false
|
||||||
clearTimeout(didLongPressCleanupTimerRef.current)
|
console.log('[sidebar-dnd-debug] press-in', {
|
||||||
didLongPressCleanupTimerRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
longPressCancelledRef.current = false
|
|
||||||
longPressArmedRef.current = false
|
|
||||||
didStartDragRef.current = false
|
|
||||||
touchStartRef.current = {
|
|
||||||
x: event.nativeEvent.pageX,
|
x: event.nativeEvent.pageX,
|
||||||
y: event.nativeEvent.pageY,
|
y: event.nativeEvent.pageY,
|
||||||
}
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handlePressOut = useCallback(() => {
|
const handlePressOut = useCallback(() => {
|
||||||
if (Platform.OS === 'web') {
|
console.log('[sidebar-dnd-debug] press-out no context-menu', {
|
||||||
return
|
didLongPress: didLongPressRef.current,
|
||||||
}
|
})
|
||||||
|
}, [])
|
||||||
if (
|
|
||||||
!shouldOpenContextMenuOnPressOut({
|
|
||||||
longPressArmed: longPressArmedRef.current,
|
|
||||||
didStartDrag: didStartDragRef.current,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
longPressCancelledRef.current = false
|
|
||||||
longPressArmedRef.current = false
|
|
||||||
didStartDragRef.current = false
|
|
||||||
touchStartRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openContextMenuAtTouchStart()
|
|
||||||
didLongPressCleanupTimerRef.current = setTimeout(() => {
|
|
||||||
didLongPressRef.current = false
|
|
||||||
didLongPressCleanupTimerRef.current = null
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
longPressCancelledRef.current = false
|
|
||||||
longPressArmedRef.current = false
|
|
||||||
didStartDragRef.current = false
|
|
||||||
touchStartRef.current = null
|
|
||||||
}, [openContextMenuAtTouchStart])
|
|
||||||
|
|
||||||
const moveMonitorGesture = useMemo(() => {
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const CANCEL_SLOP_PX = 10
|
|
||||||
const DRAG_SLOP_PX = 8
|
|
||||||
|
|
||||||
return Gesture.Pan()
|
|
||||||
.manualActivation(true)
|
|
||||||
.runOnJS(true)
|
|
||||||
.onTouchesDown((event) => {
|
|
||||||
const touch = event.changedTouches[0]
|
|
||||||
if (!touch) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
|
|
||||||
})
|
|
||||||
.onTouchesMove((event) => {
|
|
||||||
const touch = event.changedTouches[0]
|
|
||||||
if (!touch || event.numberOfTouches !== 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = touchStartRef.current
|
|
||||||
if (!start) {
|
|
||||||
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (didStartDragRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const dx = touch.absoluteX - start.x
|
|
||||||
const dy = touch.absoluteY - start.y
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
||||||
|
|
||||||
if (!longPressArmedRef.current) {
|
|
||||||
if (distance > CANCEL_SLOP_PX) {
|
|
||||||
longPressCancelledRef.current = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distance > DRAG_SLOP_PX) {
|
|
||||||
didStartDragRef.current = true
|
|
||||||
input.drag()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [input.drag])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
didLongPressRef,
|
didLongPressRef,
|
||||||
handleLongPress,
|
handleLongPress,
|
||||||
handlePressIn,
|
handlePressIn,
|
||||||
handlePressOut,
|
handlePressOut,
|
||||||
moveMonitorGesture,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +192,11 @@ function ProjectHeaderRow({
|
|||||||
collapsed,
|
collapsed,
|
||||||
onToggle,
|
onToggle,
|
||||||
drag,
|
drag,
|
||||||
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController: useContextMenu(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
@@ -339,11 +207,18 @@ 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={false}
|
enabledOnMobile
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.projectRow,
|
styles.projectRow,
|
||||||
|
isDragging && styles.projectRowDragging,
|
||||||
hovered && styles.projectRowHovered,
|
hovered && styles.projectRowHovered,
|
||||||
pressed && styles.projectRowPressed,
|
pressed && styles.projectRowPressed,
|
||||||
]}
|
]}
|
||||||
@@ -351,15 +226,10 @@ function ProjectHeaderRow({
|
|||||||
onPressOut={interaction.handlePressOut}
|
onPressOut={interaction.handlePressOut}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onLongPress={interaction.handleLongPress}
|
onLongPress={interaction.handleLongPress}
|
||||||
delayLongPress={200}
|
delayLongPress={350}
|
||||||
testID={`sidebar-project-row-${project.projectKey}`}
|
testID={`sidebar-project-row-${project.projectKey}`}
|
||||||
>
|
>
|
||||||
<View
|
<View style={styles.projectRowLeft}>
|
||||||
{...(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" />
|
||||||
) : (
|
) : (
|
||||||
@@ -378,14 +248,24 @@ 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>
|
||||||
)
|
)
|
||||||
|
|
||||||
return interaction.moveMonitorGesture ? (
|
return trigger
|
||||||
<GestureDetector gesture={interaction.moveMonitorGesture}>{trigger}</GestureDetector>
|
|
||||||
) : (
|
|
||||||
trigger
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
|
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
|
||||||
@@ -411,6 +291,7 @@ function WorkspaceRowInner({
|
|||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
drag,
|
drag,
|
||||||
|
isDragging,
|
||||||
isArchiving,
|
isArchiving,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
menuController,
|
menuController,
|
||||||
@@ -419,7 +300,6 @@ function WorkspaceRowInner({
|
|||||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
@@ -430,12 +310,17 @@ 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} />
|
||||||
@@ -454,16 +339,31 @@ 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={false}
|
enabledOnMobile
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.workspaceRow,
|
styles.workspaceRow,
|
||||||
|
isDragging && styles.workspaceRowDragging,
|
||||||
selected && styles.workspaceRowSelected,
|
selected && styles.workspaceRowSelected,
|
||||||
hovered && styles.workspaceRowHovered,
|
hovered && styles.workspaceRowHovered,
|
||||||
pressed && styles.workspaceRowPressed,
|
pressed && styles.workspaceRowPressed,
|
||||||
@@ -472,7 +372,7 @@ function WorkspaceRowInner({
|
|||||||
onPressOut={interaction.handlePressOut}
|
onPressOut={interaction.handlePressOut}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onLongPress={interaction.handleLongPress}
|
onLongPress={interaction.handleLongPress}
|
||||||
delayLongPress={200}
|
delayLongPress={350}
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
>
|
>
|
||||||
{rowChildren}
|
{rowChildren}
|
||||||
@@ -482,6 +382,7 @@ function WorkspaceRowInner({
|
|||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.workspaceRow,
|
styles.workspaceRow,
|
||||||
|
isDragging && styles.workspaceRowDragging,
|
||||||
selected && styles.workspaceRowSelected,
|
selected && styles.workspaceRowSelected,
|
||||||
hovered && styles.workspaceRowHovered,
|
hovered && styles.workspaceRowHovered,
|
||||||
pressed && styles.workspaceRowPressed,
|
pressed && styles.workspaceRowPressed,
|
||||||
@@ -490,18 +391,14 @@ function WorkspaceRowInner({
|
|||||||
onPressOut={interaction.handlePressOut}
|
onPressOut={interaction.handlePressOut}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onLongPress={interaction.handleLongPress}
|
onLongPress={interaction.handleLongPress}
|
||||||
delayLongPress={200}
|
delayLongPress={350}
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
>
|
>
|
||||||
{rowChildren}
|
{rowChildren}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
|
|
||||||
const content = interaction.moveMonitorGesture ? (
|
const content = trigger
|
||||||
<GestureDetector gesture={interaction.moveMonitorGesture}>{trigger}</GestureDetector>
|
|
||||||
) : (
|
|
||||||
trigger
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceRowContainer}>
|
<View style={styles.workspaceRowContainer}>
|
||||||
@@ -523,6 +420,7 @@ function WorkspaceRowWithMenu({
|
|||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
drag,
|
drag,
|
||||||
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
@@ -531,6 +429,7 @@ function WorkspaceRowWithMenu({
|
|||||||
showShortcutBadge: boolean
|
showShortcutBadge: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -586,6 +485,7 @@ function WorkspaceRowWithMenu({
|
|||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
menuController={contextMenu}
|
menuController={contextMenu}
|
||||||
@@ -612,6 +512,7 @@ function WorkspaceRowPlain({
|
|||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
drag,
|
drag,
|
||||||
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
@@ -620,6 +521,7 @@ function WorkspaceRowPlain({
|
|||||||
showShortcutBadge: boolean
|
showShortcutBadge: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -630,6 +532,7 @@ function WorkspaceRowPlain({
|
|||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
isArchiving={false}
|
isArchiving={false}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
menuController={null}
|
menuController={null}
|
||||||
@@ -644,6 +547,7 @@ function WorkspaceRow({
|
|||||||
showShortcutBadge,
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
drag,
|
drag,
|
||||||
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
@@ -652,6 +556,7 @@ function WorkspaceRow({
|
|||||||
showShortcutBadge: boolean
|
showShortcutBadge: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
}) {
|
}) {
|
||||||
if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) {
|
if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) {
|
||||||
@@ -663,6 +568,7 @@ function WorkspaceRow({
|
|||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -677,6 +583,7 @@ function WorkspaceRow({
|
|||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -697,7 +604,10 @@ function ProjectBlock({
|
|||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorkspaceReorder,
|
onWorkspaceReorder,
|
||||||
|
onAnyDragBegin,
|
||||||
|
onAnyDragEnd,
|
||||||
drag,
|
drag,
|
||||||
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
useNestable,
|
useNestable,
|
||||||
}: {
|
}: {
|
||||||
@@ -714,12 +624,20 @@ function ProjectBlock({
|
|||||||
onToggleCollapsed: () => void
|
onToggleCollapsed: () => void
|
||||||
onWorkspacePress?: () => void
|
onWorkspacePress?: () => void
|
||||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
||||||
|
onAnyDragBegin: () => void
|
||||||
|
onAnyDragEnd: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
|
isDragging: boolean
|
||||||
dragHandleProps?: DraggableListDragHandleProps
|
dragHandleProps?: DraggableListDragHandleProps
|
||||||
useNestable: boolean
|
useNestable: boolean
|
||||||
}) {
|
}) {
|
||||||
const renderWorkspace = useCallback(
|
const renderWorkspace = useCallback(
|
||||||
({ item, drag: workspaceDrag, dragHandleProps: workspaceDragHandleProps }: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
|
({
|
||||||
|
item,
|
||||||
|
drag: workspaceDrag,
|
||||||
|
isActive,
|
||||||
|
dragHandleProps: workspaceDragHandleProps,
|
||||||
|
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
|
||||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
|
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
|
||||||
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||||
const isSelected =
|
const isSelected =
|
||||||
@@ -741,6 +659,7 @@ function ProjectBlock({
|
|||||||
navigate(workspaceRoute as any)
|
navigate(workspaceRoute as any)
|
||||||
}}
|
}}
|
||||||
drag={workspaceDrag}
|
drag={workspaceDrag}
|
||||||
|
isDragging={isActive}
|
||||||
dragHandleProps={workspaceDragHandleProps}
|
dragHandleProps={workspaceDragHandleProps}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -764,15 +683,23 @@ function ProjectBlock({
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggle={onToggleCollapsed}
|
onToggle={onToggleCollapsed}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
|
isDragging={isDragging}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<DraggableList
|
<DraggableList
|
||||||
|
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||||
data={project.workspaces}
|
data={project.workspaces}
|
||||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||||
renderItem={renderWorkspace}
|
renderItem={renderWorkspace}
|
||||||
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
onDragIntent={onAnyDragBegin}
|
||||||
|
onDragBegin={onAnyDragBegin}
|
||||||
|
onDragRelease={onAnyDragEnd}
|
||||||
|
onDragEnd={(workspaces) => {
|
||||||
|
onWorkspaceReorder(project.projectKey, workspaces)
|
||||||
|
onAnyDragEnd()
|
||||||
|
}}
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={useNestable}
|
nestable={useNestable}
|
||||||
@@ -795,11 +722,11 @@ export function SidebarWorkspaceList({
|
|||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
const isNative = Platform.OS !== 'web'
|
|
||||||
const segments = useSegments()
|
const segments = useSegments()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
||||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||||
|
const [outerScrollEnabled, setOuterScrollEnabled] = useState(true)
|
||||||
const isTauri = getIsTauri()
|
const isTauri = getIsTauri()
|
||||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||||
@@ -938,6 +865,16 @@ export function SidebarWorkspaceList({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const lockOuterScrollForDrag = useCallback((source: string) => {
|
||||||
|
console.log('[sidebar-dnd-debug] outer scroll locked', { source })
|
||||||
|
setOuterScrollEnabled(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const unlockOuterScrollForDrag = useCallback((source: string) => {
|
||||||
|
console.log('[sidebar-dnd-debug] outer scroll unlocked', { source })
|
||||||
|
setOuterScrollEnabled(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleProjectDragEnd = useCallback(
|
const handleProjectDragEnd = useCallback(
|
||||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -996,7 +933,7 @@ export function SidebarWorkspaceList({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderProject = useCallback(
|
const renderProject = useCallback(
|
||||||
({ item, drag, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||||
return (
|
return (
|
||||||
<ProjectBlock
|
<ProjectBlock
|
||||||
project={item}
|
project={item}
|
||||||
@@ -1012,9 +949,12 @@ export function SidebarWorkspaceList({
|
|||||||
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onWorkspaceReorder={handleWorkspaceReorder}
|
onWorkspaceReorder={handleWorkspaceReorder}
|
||||||
|
onAnyDragBegin={() => lockOuterScrollForDrag(`workspace:${item.projectKey}`)}
|
||||||
|
onAnyDragEnd={() => unlockOuterScrollForDrag(`workspace:${item.projectKey}`)}
|
||||||
drag={drag}
|
drag={drag}
|
||||||
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
useNestable={isNative}
|
useNestable={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1022,7 +962,6 @@ export function SidebarWorkspaceList({
|
|||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
handleWorkspaceReorder,
|
handleWorkspaceReorder,
|
||||||
isNative,
|
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
projectIconByProjectKey,
|
projectIconByProjectKey,
|
||||||
@@ -1031,6 +970,8 @@ export function SidebarWorkspaceList({
|
|||||||
shouldReplaceWorkspaceNavigation,
|
shouldReplaceWorkspaceNavigation,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
|
lockOuterScrollForDrag,
|
||||||
|
unlockOuterScrollForDrag,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1040,13 +981,20 @@ export function SidebarWorkspaceList({
|
|||||||
<Text style={styles.emptyText}>No projects yet</Text>
|
<Text style={styles.emptyText}>No projects yet</Text>
|
||||||
) : (
|
) : (
|
||||||
<DraggableList
|
<DraggableList
|
||||||
|
testID="sidebar-project-list"
|
||||||
data={projects}
|
data={projects}
|
||||||
keyExtractor={(project) => project.projectKey}
|
keyExtractor={(project) => project.projectKey}
|
||||||
renderItem={renderProject}
|
renderItem={renderProject}
|
||||||
onDragEnd={handleProjectDragEnd}
|
onDragIntent={() => lockOuterScrollForDrag('project-list-intent')}
|
||||||
|
onDragBegin={() => lockOuterScrollForDrag('project-list-begin')}
|
||||||
|
onDragRelease={() => unlockOuterScrollForDrag('project-list-release')}
|
||||||
|
onDragEnd={(reorderedProjects) => {
|
||||||
|
handleProjectDragEnd(reorderedProjects)
|
||||||
|
unlockOuterScrollForDrag('project-list-end')
|
||||||
|
}}
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={isNative}
|
nestable={false}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
@@ -1057,25 +1005,18 @@ export function SidebarWorkspaceList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{isNative ? (
|
<ScrollView
|
||||||
<NestableScrollContainer
|
style={styles.list}
|
||||||
style={styles.list}
|
contentContainerStyle={styles.listContent}
|
||||||
contentContainerStyle={styles.listContent}
|
showsVerticalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
scrollEnabled={outerScrollEnabled}
|
||||||
testID="sidebar-project-workspace-list-scroll"
|
onScrollBeginDrag={() =>
|
||||||
>
|
console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled })
|
||||||
{content}
|
}
|
||||||
</NestableScrollContainer>
|
testID="sidebar-project-workspace-list-scroll"
|
||||||
) : (
|
>
|
||||||
<ScrollView
|
{content}
|
||||||
style={styles.list}
|
</ScrollView>
|
||||||
contentContainerStyle={styles.listContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
testID="sidebar-project-workspace-list-scroll"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1124,6 +1065,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
projectRowPressed: {
|
projectRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
projectRowDragging: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
zIndex: 3,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
projectRowLeft: {
|
projectRowLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1185,6 +1138,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceRowPressed: {
|
workspaceRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
workspaceRowDragging: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
zIndex: 3,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
},
|
||||||
workspaceRowSelected: {
|
workspaceRowSelected: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
@@ -1226,6 +1191,22 @@ 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,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
|||||||
const DRAFT_AGENT_ID = '__new_agent__'
|
const DRAFT_AGENT_ID = '__new_agent__'
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map()
|
const EMPTY_PENDING_PERMISSIONS = new Map()
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = []
|
const EMPTY_STREAM_ITEMS: StreamItem[] = []
|
||||||
|
const MAX_INITIAL_AGENT_TITLE_CHARS = 60
|
||||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: false,
|
supportsSessionPersistence: false,
|
||||||
@@ -100,6 +101,22 @@ function getValidMode(provider: AgentProvider | undefined, value: string | undef
|
|||||||
return modes.some((mode) => mode.id === value) ? value : undefined
|
return modes.some((mode) => mode.id === value) ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||||
|
const firstContentLine = prompt
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0)
|
||||||
|
if (!firstContentLine) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = firstContentLine.replace(/\s+/g, " ").trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim()
|
||||||
|
return clamped.length > 0 ? clamped : null
|
||||||
|
}
|
||||||
|
|
||||||
type DraftAgentParams = {
|
type DraftAgentParams = {
|
||||||
serverId?: string
|
serverId?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -981,9 +998,11 @@ function DraftAgentScreenContent({
|
|||||||
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
|
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
|
||||||
const trimmedModel = selectedModel.trim()
|
const trimmedModel = selectedModel.trim()
|
||||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
|
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
|
||||||
|
const derivedTitle = deriveInitialAgentTitle(trimmedPrompt)
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
|
...(derivedTitle ? { title: derivedTitle } : {}),
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||||
|
|||||||
@@ -193,7 +193,11 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
onNavigateTab(tab.tabId);
|
onNavigateTab(tab.tabId);
|
||||||
}}
|
}}
|
||||||
accessibilityLabel={tab.label}
|
accessibilityLabel={
|
||||||
|
tab.kind === "agent" && tab.titleState === "loading"
|
||||||
|
? "Loading agent title"
|
||||||
|
: tab.label
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
{...(dragHandleProps?.attributes as any)}
|
{...(dragHandleProps?.attributes as any)}
|
||||||
@@ -203,6 +207,14 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
<View style={styles.tabIcon}>{icon}</View>
|
<View style={styles.tabIcon}>{icon}</View>
|
||||||
{layout.showLabels ? (
|
{layout.showLabels ? (
|
||||||
|
tab.kind === "agent" && tab.titleState === "loading" ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.tabLabelSkeleton,
|
||||||
|
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.tabLabel,
|
styles.tabLabel,
|
||||||
@@ -213,6 +225,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -436,6 +449,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
tabLabelSkeleton: {
|
||||||
|
width: 96,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
tabLabelSkeletonWithCloseButton: {
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
tabLabelWithCloseButton: {
|
tabLabelWithCloseButton: {
|
||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,13 +44,10 @@ import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
|||||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type Agent,
|
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceTabsStore,
|
useWorkspaceTabsStore,
|
||||||
type WorkspaceTabTarget,
|
|
||||||
type WorkspaceTab,
|
|
||||||
} from "@/stores/workspace-tabs-store";
|
} from "@/stores/workspace-tabs-store";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
@@ -79,17 +76,17 @@ import {
|
|||||||
shouldRenderMissingWorkspaceDescriptor,
|
shouldRenderMissingWorkspaceDescriptor,
|
||||||
} from "@/screens/workspace/workspace-header-source";
|
} from "@/screens/workspace/workspace-header-source";
|
||||||
import {
|
import {
|
||||||
canOpenAgentTabFromRoute,
|
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} from "@/screens/workspace/workspace-agent-visibility";
|
||||||
|
import {
|
||||||
|
deriveWorkspaceTabModel,
|
||||||
|
} from "@/screens/workspace/workspace-tab-model";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||||
const EMPTY_OPEN_FILE_PATHS: string[] = [];
|
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
|
||||||
const EMPTY_WORKSPACE_TABS: WorkspaceTab[] = [];
|
const EMPTY_TAB_ORDER: string[] = [];
|
||||||
|
|
||||||
type TabAvailability = "available" | "invalid" | "unknown";
|
|
||||||
|
|
||||||
type WorkspaceScreenProps = {
|
type WorkspaceScreenProps = {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -97,41 +94,6 @@ type WorkspaceScreenProps = {
|
|||||||
routeTabId?: string | null;
|
routeTabId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyWorkspaceTabOrder(input: {
|
|
||||||
tabs: WorkspaceTabDescriptor[];
|
|
||||||
keys: string[];
|
|
||||||
}): WorkspaceTabDescriptor[] {
|
|
||||||
if (input.keys.length === 0) {
|
|
||||||
return input.tabs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKey = new Map<string, WorkspaceTabDescriptor>();
|
|
||||||
for (const tab of input.tabs) {
|
|
||||||
byKey.set(tab.key, tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
const used = new Set<string>();
|
|
||||||
const next: WorkspaceTabDescriptor[] = [];
|
|
||||||
|
|
||||||
for (const key of input.keys) {
|
|
||||||
const tab = byKey.get(key);
|
|
||||||
if (!tab) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
used.add(key);
|
|
||||||
next.push(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tab of input.tabs) {
|
|
||||||
if (used.has(tab.key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.push(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -148,57 +110,6 @@ function decodeSegment(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
|
||||||
if (provider === "claude") {
|
|
||||||
return "Claude";
|
|
||||||
}
|
|
||||||
if (provider === "codex") {
|
|
||||||
return "Codex";
|
|
||||||
}
|
|
||||||
if (!provider) {
|
|
||||||
return "Agent";
|
|
||||||
}
|
|
||||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string {
|
|
||||||
if (typeof title !== "string") {
|
|
||||||
return "Agent";
|
|
||||||
}
|
|
||||||
const normalized = title.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return "Agent";
|
|
||||||
}
|
|
||||||
if (normalized.toLowerCase() === "new agent") {
|
|
||||||
return "Agent";
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTabAvailability(input: {
|
|
||||||
tab: WorkspaceTabTarget;
|
|
||||||
agentsHydrated: boolean;
|
|
||||||
terminalsHydrated: boolean;
|
|
||||||
agentsById: Map<string, Agent>;
|
|
||||||
terminalIds: Set<string>;
|
|
||||||
}): TabAvailability {
|
|
||||||
if (input.tab.kind === "draft") {
|
|
||||||
return "available";
|
|
||||||
}
|
|
||||||
if (input.tab.kind === "agent") {
|
|
||||||
if (!input.agentsHydrated) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
|
|
||||||
}
|
|
||||||
if (input.tab.kind === "file") {
|
|
||||||
return "available";
|
|
||||||
}
|
|
||||||
if (!input.terminalsHydrated) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
return input.terminalIds.has(input.tab.terminalId) ? "available" : "invalid";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkspaceScreen({
|
export function WorkspaceScreen({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -392,11 +303,6 @@ function WorkspaceScreenContent({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||||
const areWorkspaceAgentsHydrated = useSessionStore(
|
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false
|
|
||||||
);
|
|
||||||
const areWorkspaceTerminalsHydrated = terminalsQuery.isSuccess;
|
|
||||||
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore(
|
const desktopFileExplorerOpen = usePanelStore(
|
||||||
(state) => state.desktop.fileExplorerOpen
|
(state) => state.desktop.fileExplorerOpen
|
||||||
@@ -473,14 +379,6 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const agentsById = workspaceAgentVisibility.lookupById;
|
const agentsById = workspaceAgentVisibility.lookupById;
|
||||||
|
|
||||||
const terminalIds = useMemo(() => {
|
|
||||||
const set = new Set<string>();
|
|
||||||
for (const terminal of terminals) {
|
|
||||||
set.add(terminal.id);
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
}, [terminals]);
|
|
||||||
|
|
||||||
const persistenceKey = useMemo(
|
const persistenceKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildWorkspaceTabPersistenceKey({
|
buildWorkspaceTabPersistenceKey({
|
||||||
@@ -490,119 +388,30 @@ function WorkspaceScreenContent({
|
|||||||
[normalizedServerId, normalizedWorkspaceId]
|
[normalizedServerId, normalizedWorkspaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openTabs = useWorkspaceTabsStore((state) =>
|
const uiTabs = useWorkspaceTabsStore((state) =>
|
||||||
persistenceKey
|
persistenceKey
|
||||||
? state.openTabsByWorkspace[persistenceKey] ?? EMPTY_WORKSPACE_TABS
|
? state.uiTabsByWorkspace[persistenceKey] ?? EMPTY_UI_TABS
|
||||||
: EMPTY_WORKSPACE_TABS
|
: EMPTY_UI_TABS
|
||||||
|
);
|
||||||
|
const tabOrder = useWorkspaceTabsStore((state) =>
|
||||||
|
persistenceKey
|
||||||
|
? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER
|
||||||
|
: EMPTY_TAB_ORDER
|
||||||
);
|
);
|
||||||
const openTabIdSet = useMemo(() => new Set(openTabs.map((tab) => tab.tabId)), [openTabs]);
|
|
||||||
const focusedTabId = useWorkspaceTabsStore((state) =>
|
const focusedTabId = useWorkspaceTabsStore((state) =>
|
||||||
persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
|
persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
|
||||||
);
|
);
|
||||||
const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
|
const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
|
||||||
const seedWorkspaceTabs = useWorkspaceTabsStore((state) => state.seedWorkspaceTabs);
|
|
||||||
const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
|
const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
|
||||||
const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
|
const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
|
||||||
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
||||||
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
|
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
|
||||||
const replaceWorkspaceTabTarget = useWorkspaceTabsStore((state) => state.replaceTabTarget);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (openTabs.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!areWorkspaceAgentsHydrated || !areWorkspaceTerminalsHydrated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targets: WorkspaceTabTarget[] = [];
|
|
||||||
for (const agent of workspaceAgents) {
|
|
||||||
targets.push({ kind: "agent", agentId: agent.id });
|
|
||||||
}
|
|
||||||
for (const terminal of terminals) {
|
|
||||||
targets.push({ kind: "terminal", terminalId: terminal.id });
|
|
||||||
}
|
|
||||||
seedWorkspaceTabs({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
targets,
|
|
||||||
focusedTabId: focusedTabId || null,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
areWorkspaceAgentsHydrated,
|
|
||||||
areWorkspaceTerminalsHydrated,
|
|
||||||
focusedTabId,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
openTabs.length,
|
|
||||||
seedWorkspaceTabs,
|
|
||||||
terminals,
|
|
||||||
workspaceAgents,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
|
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
|
||||||
if (!normalized || !persistenceKey) {
|
if (!normalized || !persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const alreadyOpen = openTabs.some((tab) => tab.tabId === normalized);
|
|
||||||
if (alreadyOpen) {
|
|
||||||
focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: normalized });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the canonical tab route is opened without local state (fresh load / reconnect),
|
|
||||||
// reconstruct the tab target from the tabId prefix.
|
|
||||||
if (normalized.startsWith("agent_")) {
|
|
||||||
const agentId = normalized.slice("agent_".length).trim();
|
|
||||||
if (agentId) {
|
|
||||||
if (
|
|
||||||
!canOpenAgentTabFromRoute({
|
|
||||||
agentId,
|
|
||||||
agentsHydrated: areWorkspaceAgentsHydrated,
|
|
||||||
workspaceAgentLookup: agentsById,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tabId = openOrFocusTab({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
target: { kind: "agent", agentId },
|
|
||||||
});
|
|
||||||
if (tabId) {
|
|
||||||
focusTab({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
tabId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (normalized.startsWith("terminal_")) {
|
|
||||||
const terminalId = normalized.slice("terminal_".length).trim();
|
|
||||||
if (terminalId) {
|
|
||||||
if (areWorkspaceTerminalsHydrated && !terminalIds.has(terminalId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tabId = openOrFocusTab({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
target: { kind: "terminal", terminalId },
|
|
||||||
});
|
|
||||||
if (tabId) {
|
|
||||||
focusTab({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
tabId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (normalized.startsWith("draft_")) {
|
if (normalized.startsWith("draft_")) {
|
||||||
const tabId = openDraftTab({
|
const tabId = openDraftTab({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
@@ -616,75 +425,49 @@ function WorkspaceScreenContent({
|
|||||||
tabId,
|
tabId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("file_")) {
|
||||||
|
const path = normalized.slice("file_".length).trim();
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabId = openOrFocusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
target: { kind: "file", path },
|
||||||
|
});
|
||||||
|
if (tabId) {
|
||||||
|
focusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
focusTab,
|
focusTab,
|
||||||
agentsById,
|
|
||||||
areWorkspaceAgentsHydrated,
|
|
||||||
areWorkspaceTerminalsHydrated,
|
|
||||||
openDraftTab,
|
openDraftTab,
|
||||||
openOrFocusTab,
|
openOrFocusTab,
|
||||||
openTabs,
|
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
routeTabId,
|
routeTabId,
|
||||||
terminalIds,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeTabId = useMemo(() => {
|
const tabModel = useMemo(
|
||||||
const fromRoute =
|
() =>
|
||||||
typeof routeTabId === "string" ? trimNonEmpty(routeTabId.trim()) : null;
|
deriveWorkspaceTabModel({
|
||||||
if (fromRoute && openTabIdSet.has(fromRoute)) {
|
workspaceAgents,
|
||||||
return fromRoute;
|
terminals,
|
||||||
}
|
uiTabs,
|
||||||
const fromFocus = trimNonEmpty(focusedTabId);
|
tabOrder,
|
||||||
if (fromFocus && openTabIdSet.has(fromFocus)) {
|
focusedTabId,
|
||||||
return fromFocus;
|
routeTabId,
|
||||||
}
|
}),
|
||||||
return openTabs[0]?.tabId ?? null;
|
[focusedTabId, routeTabId, tabOrder, terminals, uiTabs, workspaceAgents]
|
||||||
}, [focusedTabId, openTabIdSet, openTabs, routeTabId]);
|
);
|
||||||
|
const activeTabId = tabModel.activeTabId;
|
||||||
useEffect(() => {
|
|
||||||
if (!persistenceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidTabIds = openTabs
|
|
||||||
.filter((tab) => {
|
|
||||||
const availability = resolveTabAvailability({
|
|
||||||
tab: tab.target,
|
|
||||||
agentsHydrated: areWorkspaceAgentsHydrated,
|
|
||||||
terminalsHydrated: areWorkspaceTerminalsHydrated,
|
|
||||||
agentsById,
|
|
||||||
terminalIds,
|
|
||||||
});
|
|
||||||
return availability === "invalid";
|
|
||||||
})
|
|
||||||
.map((tab) => tab.tabId);
|
|
||||||
|
|
||||||
if (invalidTabIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tabId of invalidTabIds) {
|
|
||||||
closeWorkspaceTab({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
tabId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
agentsById,
|
|
||||||
areWorkspaceAgentsHydrated,
|
|
||||||
areWorkspaceTerminalsHydrated,
|
|
||||||
closeWorkspaceTab,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
openTabs,
|
|
||||||
persistenceKey,
|
|
||||||
terminalIds,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTabId || !persistenceKey) {
|
if (!activeTabId || !persistenceKey) {
|
||||||
@@ -711,83 +494,12 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
}, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]);
|
}, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]);
|
||||||
|
|
||||||
const activeTab = useMemo(() => openTabs.find((tab) => tab.tabId === activeTabId) ?? null, [
|
const activeTab = tabModel.activeTab;
|
||||||
activeTabId,
|
|
||||||
openTabs,
|
|
||||||
]);
|
|
||||||
const activeTabAvailability = useMemo(() => {
|
|
||||||
if (!activeTab) {
|
|
||||||
return "unknown" as TabAvailability;
|
|
||||||
}
|
|
||||||
return resolveTabAvailability({
|
|
||||||
tab: activeTab.target,
|
|
||||||
agentsHydrated: areWorkspaceAgentsHydrated,
|
|
||||||
terminalsHydrated: areWorkspaceTerminalsHydrated,
|
|
||||||
agentsById,
|
|
||||||
terminalIds,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
activeTab,
|
|
||||||
agentsById,
|
|
||||||
areWorkspaceAgentsHydrated,
|
|
||||||
areWorkspaceTerminalsHydrated,
|
|
||||||
terminalIds,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(() => {
|
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||||
const next: WorkspaceTabDescriptor[] = [];
|
() => tabModel.tabs.map((tab) => tab.descriptor),
|
||||||
for (const tab of openTabs) {
|
[tabModel.tabs]
|
||||||
const target = tab.target;
|
);
|
||||||
if (target.kind === "draft") {
|
|
||||||
next.push({
|
|
||||||
key: tab.tabId,
|
|
||||||
tabId: tab.tabId,
|
|
||||||
kind: "draft",
|
|
||||||
draftId: target.draftId,
|
|
||||||
label: "Draft",
|
|
||||||
subtitle: "Draft",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (target.kind === "agent") {
|
|
||||||
const agent = sessionAgents?.get(target.agentId) ?? null;
|
|
||||||
const provider = agent?.provider ?? "claude";
|
|
||||||
next.push({
|
|
||||||
key: tab.tabId,
|
|
||||||
tabId: tab.tabId,
|
|
||||||
kind: "agent",
|
|
||||||
agentId: target.agentId,
|
|
||||||
provider,
|
|
||||||
label: resolveWorkspaceAgentTabLabel(agent?.title),
|
|
||||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (target.kind === "terminal") {
|
|
||||||
const terminal = terminals.find((t) => t.id === target.terminalId) ?? null;
|
|
||||||
next.push({
|
|
||||||
key: tab.tabId,
|
|
||||||
tabId: tab.tabId,
|
|
||||||
kind: "terminal",
|
|
||||||
terminalId: target.terminalId,
|
|
||||||
label: terminal?.name ?? "Terminal",
|
|
||||||
subtitle: "Terminal",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const filePath = target.path;
|
|
||||||
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
|
|
||||||
next.push({
|
|
||||||
key: tab.tabId,
|
|
||||||
tabId: tab.tabId,
|
|
||||||
kind: "file",
|
|
||||||
filePath,
|
|
||||||
label: fileName,
|
|
||||||
subtitle: filePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}, [openTabs, sessionAgents, terminals]);
|
|
||||||
|
|
||||||
const handleReorderTabs = useCallback(
|
const handleReorderTabs = useCallback(
|
||||||
(nextTabs: WorkspaceTabDescriptor[]) => {
|
(nextTabs: WorkspaceTabDescriptor[]) => {
|
||||||
@@ -851,7 +563,7 @@ function WorkspaceScreenContent({
|
|||||||
() =>
|
() =>
|
||||||
tabs.map((tab) => ({
|
tabs.map((tab) => ({
|
||||||
id: tab.key,
|
id: tab.key,
|
||||||
label: tab.label,
|
label: tab.kind === "agent" && tab.titleState === "loading" ? "Loading..." : tab.label,
|
||||||
description: tab.subtitle,
|
description: tab.subtitle,
|
||||||
})),
|
})),
|
||||||
[tabs]
|
[tabs]
|
||||||
@@ -859,6 +571,9 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const activeTabLabel = useMemo(() => {
|
const activeTabLabel = useMemo(() => {
|
||||||
const active = tabs.find((tab) => tab.key === activeTabKey);
|
const active = tabs.find((tab) => tab.key === activeTabKey);
|
||||||
|
if (active?.kind === "agent" && active.titleState === "loading") {
|
||||||
|
return "Loading...";
|
||||||
|
}
|
||||||
return active?.label ?? "Select tab";
|
return active?.label ?? "Select tab";
|
||||||
}, [activeTabKey, tabs]);
|
}, [activeTabKey, tabs]);
|
||||||
|
|
||||||
@@ -1218,15 +933,6 @@ function WorkspaceScreenContent({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (activeTabAvailability === "invalid") {
|
|
||||||
return (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={styles.emptyStateText}>
|
|
||||||
This tab is no longer available. It will be removed from your workspace.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.kind === "draft") {
|
if (target.kind === "draft") {
|
||||||
return (
|
return (
|
||||||
@@ -1237,19 +943,26 @@ function WorkspaceScreenContent({
|
|||||||
draftId={target.draftId}
|
draftId={target.draftId}
|
||||||
onCreated={(agentSnapshot) => {
|
onCreated={(agentSnapshot) => {
|
||||||
const tabId = activeTabId ?? target.draftId;
|
const tabId = activeTabId ?? target.draftId;
|
||||||
replaceWorkspaceTabTarget({
|
const nextAgentTabId = openOrFocusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
target: { kind: "agent", agentId: agentSnapshot.id },
|
||||||
|
});
|
||||||
|
closeWorkspaceTab({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "agent", agentId: agentSnapshot.id },
|
|
||||||
});
|
});
|
||||||
|
if (nextAgentTabId) {
|
||||||
|
navigateToTabId(nextAgentTabId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.kind === "agent") {
|
if (target.kind === "agent") {
|
||||||
return (
|
return (
|
||||||
<AgentReadyScreen
|
<AgentReadyScreen
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
agentId={target.agentId}
|
agentId={target.agentId}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||||
|
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
|
function makeAgent(input: {
|
||||||
|
id: string;
|
||||||
|
provider?: Agent["provider"];
|
||||||
|
title?: string | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
lastActivityAt?: Date;
|
||||||
|
requiresAttention?: boolean;
|
||||||
|
attentionReason?: Agent["attentionReason"];
|
||||||
|
}): Agent {
|
||||||
|
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
|
||||||
|
const lastActivityAt = input.lastActivityAt ?? createdAt;
|
||||||
|
return {
|
||||||
|
serverId: "srv",
|
||||||
|
id: input.id,
|
||||||
|
provider: input.provider ?? "codex",
|
||||||
|
status: "idle",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lastActivityAt,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: {
|
||||||
|
provider: input.provider ?? "codex",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
title: input.title ?? null,
|
||||||
|
cwd: "/repo/worktree",
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: null,
|
||||||
|
labels: {},
|
||||||
|
requiresAttention: input.requiresAttention ?? false,
|
||||||
|
attentionReason: input.attentionReason ?? null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
archivedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deriveWorkspaceTabModel", () => {
|
||||||
|
it("derives agent and terminal tabs from domain state, not UI membership", () => {
|
||||||
|
const model = deriveWorkspaceTabModel({
|
||||||
|
workspaceAgents: [
|
||||||
|
makeAgent({ id: "agent-a", title: "Build API" }),
|
||||||
|
makeAgent({ id: "agent-b", title: "" }),
|
||||||
|
],
|
||||||
|
terminals: [{ id: "term-1", name: "shell" }],
|
||||||
|
uiTabs: [],
|
||||||
|
tabOrder: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||||
|
"agent_agent-a",
|
||||||
|
"agent_agent-b",
|
||||||
|
"terminal_term-1",
|
||||||
|
]);
|
||||||
|
const firstAgent = model.tabs[0]?.descriptor;
|
||||||
|
const secondAgent = model.tabs[1]?.descriptor;
|
||||||
|
expect(firstAgent?.kind === "agent" ? firstAgent.titleState : null).toBe("ready");
|
||||||
|
expect(secondAgent?.kind === "agent" ? secondAgent.titleState : null).toBe("loading");
|
||||||
|
expect(secondAgent?.label).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps draft and file tabs as explicit UI tabs", () => {
|
||||||
|
const uiTabs: WorkspaceTab[] = [
|
||||||
|
{
|
||||||
|
tabId: "draft_123",
|
||||||
|
target: { kind: "draft", draftId: "draft_123" },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tabId: "file_/repo/worktree/README.md",
|
||||||
|
target: { kind: "file", path: "/repo/worktree/README.md" },
|
||||||
|
createdAt: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const model = deriveWorkspaceTabModel({
|
||||||
|
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })],
|
||||||
|
terminals: [],
|
||||||
|
uiTabs,
|
||||||
|
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies stored order and appends newly-derived tabs deterministically", () => {
|
||||||
|
const model = deriveWorkspaceTabModel({
|
||||||
|
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||||
|
terminals: [{ id: "term-1", name: "zsh" }],
|
||||||
|
uiTabs: [],
|
||||||
|
tabOrder: ["terminal_term-1", "agent_agent-b"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||||
|
"terminal_term-1",
|
||||||
|
"agent_agent-b",
|
||||||
|
"agent_agent-a",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers route tab, then focused tab, then first tab for active selection", () => {
|
||||||
|
const base = {
|
||||||
|
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||||
|
terminals: [],
|
||||||
|
uiTabs: [],
|
||||||
|
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceTabModel({
|
||||||
|
...base,
|
||||||
|
focusedTabId: "agent_agent-b",
|
||||||
|
}).activeTabId
|
||||||
|
).toBe("agent_agent-b");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceTabModel({
|
||||||
|
...base,
|
||||||
|
focusedTabId: "agent_agent-b",
|
||||||
|
routeTabId: "agent_agent-a",
|
||||||
|
}).activeTabId
|
||||||
|
).toBe("agent_agent-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers regression: non-archived attention agent remains visible even if UI tabs omitted it", () => {
|
||||||
|
const offending = makeAgent({
|
||||||
|
id: "offender",
|
||||||
|
title: "Needs permission",
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "permission",
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = deriveWorkspaceTabModel({
|
||||||
|
workspaceAgents: [offending],
|
||||||
|
terminals: [],
|
||||||
|
uiTabs: [
|
||||||
|
{
|
||||||
|
tabId: "draft_123",
|
||||||
|
target: { kind: "draft", draftId: "draft_123" },
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tabOrder: ["draft_123"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type { Agent } from "@/stores/session-store";
|
||||||
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
|
||||||
|
type TerminalLike = {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceDerivedTab = {
|
||||||
|
descriptor: WorkspaceTabDescriptor;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceTabModel = {
|
||||||
|
tabs: WorkspaceDerivedTab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
activeTab: WorkspaceDerivedTab | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||||
|
if (provider === "claude") {
|
||||||
|
return "Claude";
|
||||||
|
}
|
||||||
|
if (provider === "codex") {
|
||||||
|
return "Codex";
|
||||||
|
}
|
||||||
|
if (!provider) {
|
||||||
|
return "Agent";
|
||||||
|
}
|
||||||
|
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
||||||
|
if (typeof title !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = title.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalized.toLowerCase() === "new agent") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null {
|
||||||
|
if (!tab || typeof tab !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tabId = trimNonEmpty(tab.tabId);
|
||||||
|
if (!tabId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!tab.target || typeof tab.target !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "draft") {
|
||||||
|
const draftId = trimNonEmpty(tab.target.draftId);
|
||||||
|
if (!draftId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabId,
|
||||||
|
target: { kind: "draft", draftId },
|
||||||
|
createdAt: tab.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "file") {
|
||||||
|
const path = trimNonEmpty(tab.target.path);
|
||||||
|
if (!path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabId,
|
||||||
|
target: { kind: "file", path: path.replace(/\\/g, "/") },
|
||||||
|
createdAt: tab.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
||||||
|
if (target.kind === "draft") {
|
||||||
|
return target.draftId;
|
||||||
|
}
|
||||||
|
if (target.kind === "agent") {
|
||||||
|
return `agent_${target.agentId}`;
|
||||||
|
}
|
||||||
|
if (target.kind === "terminal") {
|
||||||
|
return `terminal_${target.terminalId}`;
|
||||||
|
}
|
||||||
|
return `file_${target.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveWorkspaceTabModel(input: {
|
||||||
|
workspaceAgents: Agent[];
|
||||||
|
terminals: TerminalLike[];
|
||||||
|
uiTabs: WorkspaceTab[];
|
||||||
|
tabOrder: string[];
|
||||||
|
focusedTabId?: string | null;
|
||||||
|
routeTabId?: string | null;
|
||||||
|
}): WorkspaceTabModel {
|
||||||
|
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
||||||
|
|
||||||
|
for (const agent of input.workspaceAgents) {
|
||||||
|
const target: WorkspaceTabTarget = { kind: "agent", agentId: agent.id };
|
||||||
|
const tabId = buildWorkspaceTabId(target);
|
||||||
|
const label = resolveWorkspaceAgentTabLabel(agent.title);
|
||||||
|
tabsById.set(tabId, {
|
||||||
|
target,
|
||||||
|
descriptor: {
|
||||||
|
key: tabId,
|
||||||
|
tabId,
|
||||||
|
kind: "agent",
|
||||||
|
agentId: agent.id,
|
||||||
|
provider: agent.provider,
|
||||||
|
label: label ?? "",
|
||||||
|
subtitle: `${formatProviderLabel(agent.provider)} agent`,
|
||||||
|
titleState: label ? "ready" : "loading",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const terminal of input.terminals) {
|
||||||
|
const target: WorkspaceTabTarget = { kind: "terminal", terminalId: terminal.id };
|
||||||
|
const tabId = buildWorkspaceTabId(target);
|
||||||
|
tabsById.set(tabId, {
|
||||||
|
target,
|
||||||
|
descriptor: {
|
||||||
|
key: tabId,
|
||||||
|
tabId,
|
||||||
|
kind: "terminal",
|
||||||
|
terminalId: terminal.id,
|
||||||
|
label: trimNonEmpty(terminal.name) ?? "Terminal",
|
||||||
|
subtitle: "Terminal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUiTabs = input.uiTabs
|
||||||
|
.map((tab) => normalizeUiTab(tab))
|
||||||
|
.filter((tab): tab is WorkspaceTab => tab !== null)
|
||||||
|
.sort((left, right) => left.createdAt - right.createdAt);
|
||||||
|
|
||||||
|
for (const tab of normalizedUiTabs) {
|
||||||
|
if (tab.target.kind === "draft") {
|
||||||
|
tabsById.set(tab.tabId, {
|
||||||
|
target: tab.target,
|
||||||
|
descriptor: {
|
||||||
|
key: tab.tabId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
kind: "draft",
|
||||||
|
draftId: tab.target.draftId,
|
||||||
|
label: "Draft",
|
||||||
|
subtitle: "Draft",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.target.kind === "file") {
|
||||||
|
const filePath = tab.target.path;
|
||||||
|
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||||
|
tabsById.set(tab.tabId, {
|
||||||
|
target: tab.target,
|
||||||
|
descriptor: {
|
||||||
|
key: tab.tabId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
kind: "file",
|
||||||
|
filePath,
|
||||||
|
label: fileName,
|
||||||
|
subtitle: filePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedTabIds: string[] = [];
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const tabId of input.tabOrder) {
|
||||||
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
|
if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.add(normalizedTabId);
|
||||||
|
orderedTabIds.push(normalizedTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tabId of tabsById.keys()) {
|
||||||
|
if (used.has(tabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.add(tabId);
|
||||||
|
orderedTabIds.push(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = orderedTabIds
|
||||||
|
.map((tabId) => tabsById.get(tabId) ?? null)
|
||||||
|
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
|
||||||
|
|
||||||
|
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
|
||||||
|
const routeTabId = trimNonEmpty(input.routeTabId);
|
||||||
|
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||||
|
|
||||||
|
const activeTabId = routeTabId && openTabIds.has(routeTabId)
|
||||||
|
? routeTabId
|
||||||
|
: focusedTabId && openTabIds.has(focusedTabId)
|
||||||
|
? focusedTabId
|
||||||
|
: tabs[0]?.descriptor.tabId ?? null;
|
||||||
|
|
||||||
|
const activeTab = activeTabId
|
||||||
|
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
activeTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type WorkspaceTabDescriptor =
|
|||||||
provider: Agent["provider"];
|
provider: Agent["provider"];
|
||||||
label: string;
|
label: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
titleState: "ready" | "loading";
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUiTarget(target: WorkspaceTabTarget): target is Extract<WorkspaceTabTarget, { kind: "draft" | "file" }> {
|
||||||
|
return target.kind === "draft" || target.kind === "file";
|
||||||
|
}
|
||||||
|
|
||||||
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||||
if (target.kind === "draft") {
|
if (target.kind === "draft") {
|
||||||
return target.draftId;
|
return target.draftId;
|
||||||
@@ -71,39 +75,38 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
|||||||
if (target.kind === "terminal") {
|
if (target.kind === "terminal") {
|
||||||
return `terminal_${target.terminalId}`;
|
return `terminal_${target.terminalId}`;
|
||||||
}
|
}
|
||||||
// File tabs are session-only, so stable IDs are still useful for in-session routing.
|
|
||||||
return `file_${target.path}`;
|
return `file_${target.path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
function normalizeTabOrder(list: unknown): string[] {
|
||||||
if (left.kind !== right.kind) {
|
if (!Array.isArray(list)) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
if (left.kind === "draft" && right.kind === "draft") {
|
const next: string[] = [];
|
||||||
return left.draftId === right.draftId;
|
const used = new Set<string>();
|
||||||
|
for (const value of list) {
|
||||||
|
const tabId = trimNonEmpty(typeof value === "string" ? value : null);
|
||||||
|
if (!tabId || used.has(tabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.add(tabId);
|
||||||
|
next.push(tabId);
|
||||||
}
|
}
|
||||||
if (left.kind === "agent" && right.kind === "agent") {
|
return next;
|
||||||
return left.agentId === right.agentId;
|
}
|
||||||
|
|
||||||
|
function ensureInOrder(input: { current: string[]; tabId: string }): string[] {
|
||||||
|
if (input.current.includes(input.tabId)) {
|
||||||
|
return input.current;
|
||||||
}
|
}
|
||||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
return [...input.current, input.tabId];
|
||||||
return left.terminalId === right.terminalId;
|
|
||||||
}
|
|
||||||
if (left.kind === "file" && right.kind === "file") {
|
|
||||||
return left.path === right.path;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceTabsState = {
|
type WorkspaceTabsState = {
|
||||||
openTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||||
|
tabOrderByWorkspace: Record<string, string[]>;
|
||||||
focusedTabIdByWorkspace: Record<string, string>;
|
focusedTabIdByWorkspace: Record<string, string>;
|
||||||
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
||||||
seedWorkspaceTabs: (input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
targets: WorkspaceTabTarget[];
|
|
||||||
focusedTabId?: string | null;
|
|
||||||
}) => void;
|
|
||||||
openOrFocusTab: (input: {
|
openOrFocusTab: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -112,89 +115,24 @@ type WorkspaceTabsState = {
|
|||||||
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||||
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||||
replaceTabTarget: (input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
tabId: string;
|
|
||||||
target: WorkspaceTabTarget;
|
|
||||||
}) => void;
|
|
||||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
openTabsByWorkspace: {},
|
uiTabsByWorkspace: {},
|
||||||
|
tabOrderByWorkspace: {},
|
||||||
focusedTabIdByWorkspace: {},
|
focusedTabIdByWorkspace: {},
|
||||||
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
const normalizedDraftId = trimNonEmpty(draftId);
|
const normalizedDraftId = trimNonEmpty(draftId);
|
||||||
if (!key || !normalizedDraftId) {
|
if (!normalizedDraftId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return get().openOrFocusTab({
|
||||||
const target: WorkspaceTabTarget = { kind: "draft", draftId: normalizedDraftId };
|
serverId,
|
||||||
const tabId = buildDeterministicTabId(target);
|
workspaceId,
|
||||||
const now = Date.now();
|
target: { kind: "draft", draftId: normalizedDraftId },
|
||||||
set((state) => {
|
|
||||||
const current = state.openTabsByWorkspace[key] ?? [];
|
|
||||||
if (current.some((tab) => tab.tabId === tabId)) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
focusedTabIdByWorkspace: {
|
|
||||||
...state.focusedTabIdByWorkspace,
|
|
||||||
[key]: tabId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const nextTabs: WorkspaceTab[] = [...current, { tabId, target, createdAt: now }];
|
|
||||||
return {
|
|
||||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
|
||||||
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return tabId;
|
|
||||||
},
|
|
||||||
seedWorkspaceTabs: ({ serverId, workspaceId, targets, focusedTabId }) => {
|
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
if (!key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const nextTabs: WorkspaceTab[] = [];
|
|
||||||
const used = new Set<string>();
|
|
||||||
for (const raw of targets) {
|
|
||||||
const normalized = normalizeTabTarget(raw);
|
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const tabId = buildDeterministicTabId(normalized);
|
|
||||||
if (used.has(tabId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
used.add(tabId);
|
|
||||||
nextTabs.push({ tabId, target: normalized, createdAt: now });
|
|
||||||
}
|
|
||||||
if (nextTabs.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
set((state) => {
|
|
||||||
const current = state.openTabsByWorkspace[key] ?? [];
|
|
||||||
if (current.length > 0) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const resolvedFocus = trimNonEmpty(focusedTabId) ?? nextTabs[0]?.tabId ?? null;
|
|
||||||
return {
|
|
||||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
|
||||||
...(resolvedFocus
|
|
||||||
? {
|
|
||||||
focusedTabIdByWorkspace: {
|
|
||||||
...state.focusedTabIdByWorkspace,
|
|
||||||
[key]: resolvedFocus,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||||
@@ -203,29 +141,50 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
if (!key || !normalizedTarget) {
|
if (!key || !normalizedTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabId = buildDeterministicTabId(normalizedTarget);
|
const tabId = buildDeterministicTabId(normalizedTarget);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.openTabsByWorkspace[key] ?? [];
|
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||||
const existing = current.find((tab) => tab.tabId === tabId);
|
const nextOrder = ensureInOrder({ current: currentOrder, tabId });
|
||||||
if (existing) {
|
|
||||||
if (state.focusedTabIdByWorkspace[key] === tabId) {
|
if (!isUiTarget(normalizedTarget)) {
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...state,
|
tabOrderByWorkspace:
|
||||||
|
nextOrder === currentOrder
|
||||||
|
? state.tabOrderByWorkspace
|
||||||
|
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||||
focusedTabIdByWorkspace: {
|
focusedTabIdByWorkspace: {
|
||||||
...state.focusedTabIdByWorkspace,
|
...state.focusedTabIdByWorkspace,
|
||||||
[key]: tabId,
|
[key]: tabId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const nextTabs: WorkspaceTab[] = [...current, { tabId, target: normalizedTarget, createdAt: now }];
|
|
||||||
|
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||||
|
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === tabId);
|
||||||
|
const nextTabs =
|
||||||
|
existingIndex >= 0
|
||||||
|
? currentTabs
|
||||||
|
: [...currentTabs, { tabId, target: normalizedTarget, createdAt: now }];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
uiTabsByWorkspace:
|
||||||
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
nextTabs === currentTabs
|
||||||
|
? state.uiTabsByWorkspace
|
||||||
|
: { ...state.uiTabsByWorkspace, [key]: nextTabs },
|
||||||
|
tabOrderByWorkspace:
|
||||||
|
nextOrder === currentOrder
|
||||||
|
? state.tabOrderByWorkspace
|
||||||
|
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||||
|
focusedTabIdByWorkspace: {
|
||||||
|
...state.focusedTabIdByWorkspace,
|
||||||
|
[key]: tabId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return tabId;
|
return tabId;
|
||||||
},
|
},
|
||||||
focusTab: ({ serverId, workspaceId, tabId }) => {
|
focusTab: ({ serverId, workspaceId, tabId }) => {
|
||||||
@@ -253,25 +212,36 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
if (!key || !normalizedTabId) {
|
if (!key || !normalizedTabId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentTabs = state.openTabsByWorkspace[key] ?? [];
|
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||||
if (currentTabs.length === 0 || !currentTabs.some((tab) => tab.tabId === normalizedTabId)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
|
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
|
||||||
const nextOpenTabsByWorkspace =
|
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||||
|
const nextOrder = currentOrder.filter((value) => value !== normalizedTabId);
|
||||||
|
|
||||||
|
const nextUiTabsByWorkspace =
|
||||||
nextTabs.length === 0
|
nextTabs.length === 0
|
||||||
? (() => {
|
? (() => {
|
||||||
const { [key]: _removed, ...rest } = state.openTabsByWorkspace;
|
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
|
||||||
return rest;
|
return rest;
|
||||||
})()
|
})()
|
||||||
: { ...state.openTabsByWorkspace, [key]: nextTabs };
|
: nextTabs.length === currentTabs.length
|
||||||
|
? state.uiTabsByWorkspace
|
||||||
|
: { ...state.uiTabsByWorkspace, [key]: nextTabs };
|
||||||
|
|
||||||
|
const nextTabOrderByWorkspace =
|
||||||
|
nextOrder.length === 0
|
||||||
|
? (() => {
|
||||||
|
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: nextOrder.length === currentOrder.length
|
||||||
|
? state.tabOrderByWorkspace
|
||||||
|
: { ...state.tabOrderByWorkspace, [key]: nextOrder };
|
||||||
|
|
||||||
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
|
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
|
||||||
const nextFocused =
|
const nextFocused =
|
||||||
currentFocused !== normalizedTabId
|
currentFocused !== normalizedTabId ? currentFocused : nextOrder[nextOrder.length - 1] ?? null;
|
||||||
? currentFocused
|
|
||||||
: nextTabs[nextTabs.length - 1]?.tabId ?? null;
|
|
||||||
const nextFocusedByWorkspace = (() => {
|
const nextFocusedByWorkspace = (() => {
|
||||||
if (!nextFocused) {
|
if (!nextFocused) {
|
||||||
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
||||||
@@ -280,8 +250,18 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const tabsChanged = nextTabs.length !== currentTabs.length;
|
||||||
|
const orderChanged = nextOrder.length !== currentOrder.length;
|
||||||
|
const focusChanged =
|
||||||
|
(state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null);
|
||||||
|
|
||||||
|
if (!tabsChanged && !orderChanged && !focusChanged) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||||
|
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -291,75 +271,29 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = tabIds.map((id) => id.trim()).filter(Boolean);
|
|
||||||
set((state) => {
|
|
||||||
const current = state.openTabsByWorkspace[key] ?? [];
|
|
||||||
if (current.length <= 1) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const byId = new Map(current.map((tab) => [tab.tabId, tab]));
|
|
||||||
const used = new Set<string>();
|
|
||||||
const next: WorkspaceTab[] = [];
|
|
||||||
for (const id of normalized) {
|
|
||||||
const tab = byId.get(id);
|
|
||||||
if (!tab || used.has(id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
used.add(id);
|
|
||||||
next.push(tab);
|
|
||||||
}
|
|
||||||
for (const tab of current) {
|
|
||||||
if (used.has(tab.tabId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.push(tab);
|
|
||||||
}
|
|
||||||
if (next.length !== current.length) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
let same = true;
|
|
||||||
for (let i = 0; i < next.length; i += 1) {
|
|
||||||
if (next[i]?.tabId !== current[i]?.tabId) {
|
|
||||||
same = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (same) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: next },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
replaceTabTarget: ({ serverId, workspaceId, tabId, target }) => {
|
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
const normalizedTabId = trimNonEmpty(tabId);
|
|
||||||
const normalizedTarget = normalizeTabTarget(target);
|
|
||||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const normalized = normalizeTabOrder(tabIds);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.openTabsByWorkspace[key] ?? [];
|
const current = state.tabOrderByWorkspace[key] ?? [];
|
||||||
const idx = current.findIndex((tab) => tab.tabId === normalizedTabId);
|
if (current.length === normalized.length) {
|
||||||
if (idx < 0) {
|
let same = true;
|
||||||
return state;
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
if (current[i] !== normalized[i]) {
|
||||||
|
same = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (same) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const existing = current[idx];
|
|
||||||
if (existing && targetsEqual(existing.target, normalizedTarget)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const nextTabs = [...current];
|
|
||||||
nextTabs[idx] = {
|
|
||||||
tabId: normalizedTabId,
|
|
||||||
target: normalizedTarget,
|
|
||||||
createdAt: existing?.createdAt ?? Date.now(),
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
tabOrderByWorkspace: {
|
||||||
|
...state.tabOrderByWorkspace,
|
||||||
|
[key]: normalized,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -368,99 +302,186 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return get().openTabsByWorkspace[key] ?? [];
|
return get().uiTabsByWorkspace[key] ?? [];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "workspace-tabs-state",
|
name: "workspace-tabs-state",
|
||||||
version: 3,
|
version: 4,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
const nextOpenTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||||
for (const key in state.openTabsByWorkspace) {
|
for (const key in state.uiTabsByWorkspace) {
|
||||||
const tabs = state.openTabsByWorkspace[key] ?? [];
|
const tabs = (state.uiTabsByWorkspace[key] ?? []).filter((tab) => isUiTarget(tab.target));
|
||||||
const persistedTabs = tabs.filter((tab) => tab.target.kind !== "file");
|
if (tabs.length > 0) {
|
||||||
if (persistedTabs.length > 0) {
|
nextUiTabsByWorkspace[key] = tabs;
|
||||||
nextOpenTabsByWorkspace[key] = persistedTabs;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextTabOrderByWorkspace: Record<string, string[]> = {};
|
||||||
|
for (const key in state.tabOrderByWorkspace) {
|
||||||
|
const order = normalizeTabOrder(state.tabOrderByWorkspace[key]);
|
||||||
|
if (order.length > 0) {
|
||||||
|
nextTabOrderByWorkspace[key] = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFocusedTabIdByWorkspace: Record<string, string> = {};
|
||||||
|
for (const key in state.focusedTabIdByWorkspace) {
|
||||||
|
const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
|
||||||
|
if (focusedTabId) {
|
||||||
|
nextFocusedTabIdByWorkspace[key] = focusedTabId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||||
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
|
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||||
} as WorkspaceTabsState;
|
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
migrate: (persistedState) => {
|
migrate: (persistedState) => {
|
||||||
// Previous versions stored tab order + last focused tab by workspace using agent/terminal targets.
|
|
||||||
const legacy = persistedState as
|
const legacy = persistedState as
|
||||||
| {
|
| {
|
||||||
version?: number;
|
version?: number;
|
||||||
state?: any;
|
state?: any;
|
||||||
lastFocusedTabByWorkspace?: Record<string, any>;
|
openTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
||||||
|
uiTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
||||||
|
focusedTabIdByWorkspace?: Record<string, string>;
|
||||||
tabOrderByWorkspace?: Record<string, string[]>;
|
tabOrderByWorkspace?: Record<string, string[]>;
|
||||||
|
lastFocusedTabByWorkspace?: Record<string, any>;
|
||||||
|
tabOrderLegacyByWorkspace?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const rawState = (legacy as any)?.state ?? legacy ?? {};
|
const rawState = (legacy as any)?.state ?? legacy ?? {};
|
||||||
const legacyFocused = rawState.lastFocusedTabByWorkspace ?? {};
|
|
||||||
const legacyOrder = rawState.tabOrderByWorkspace ?? {};
|
|
||||||
|
|
||||||
const openTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
const rawUiTabsByWorkspace =
|
||||||
|
rawState.uiTabsByWorkspace ?? rawState.openTabsByWorkspace ?? legacy?.uiTabsByWorkspace ?? legacy?.openTabsByWorkspace ?? {};
|
||||||
|
const rawFocused =
|
||||||
|
rawState.focusedTabIdByWorkspace ?? legacy?.focusedTabIdByWorkspace ?? rawState.lastFocusedTabByWorkspace ?? {};
|
||||||
|
const rawOrder =
|
||||||
|
rawState.tabOrderByWorkspace ?? legacy?.tabOrderByWorkspace ?? rawState.tabOrderByWorkspace ?? {};
|
||||||
|
const legacyOrder = rawState.tabOrderByWorkspace ?? rawState.tabOrderLegacyByWorkspace ?? {};
|
||||||
|
|
||||||
|
const uiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||||
|
const tabOrderByWorkspace: Record<string, string[]> = {};
|
||||||
const focusedTabIdByWorkspace: Record<string, string> = {};
|
const focusedTabIdByWorkspace: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const key in rawUiTabsByWorkspace) {
|
||||||
|
const entries = Array.isArray(rawUiTabsByWorkspace[key]) ? rawUiTabsByWorkspace[key] : [];
|
||||||
|
const nextUiTabs: WorkspaceTab[] = [];
|
||||||
|
const orderFromTabs: string[] = [];
|
||||||
|
const usedOrder = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawTab of entries) {
|
||||||
|
if (!rawTab || typeof rawTab !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target);
|
||||||
|
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
|
||||||
|
if (!normalizedTarget) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget);
|
||||||
|
if (!usedOrder.has(tabId)) {
|
||||||
|
usedOrder.add(tabId);
|
||||||
|
orderFromTabs.push(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUiTarget(normalizedTarget)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUiTabs.push({
|
||||||
|
tabId,
|
||||||
|
target: normalizedTarget,
|
||||||
|
createdAt:
|
||||||
|
typeof (rawTab as WorkspaceTab).createdAt === "number"
|
||||||
|
? (rawTab as WorkspaceTab).createdAt
|
||||||
|
: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextUiTabs.length > 0) {
|
||||||
|
uiTabsByWorkspace[key] = nextUiTabs;
|
||||||
|
}
|
||||||
|
if (orderFromTabs.length > 0) {
|
||||||
|
tabOrderByWorkspace[key] = orderFromTabs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in rawOrder) {
|
||||||
|
const normalizedOrder = normalizeTabOrder(rawOrder[key]);
|
||||||
|
if (normalizedOrder.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = tabOrderByWorkspace[key] ?? [];
|
||||||
|
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]);
|
||||||
|
}
|
||||||
|
|
||||||
for (const key in legacyOrder) {
|
for (const key in legacyOrder) {
|
||||||
const list = legacyOrder[key];
|
const list = legacyOrder[key];
|
||||||
if (!Array.isArray(list) || list.length === 0) {
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const normalizedLegacyOrder: string[] = [];
|
||||||
const tabs: WorkspaceTab[] = [];
|
|
||||||
for (const entry of list) {
|
for (const entry of list) {
|
||||||
const raw = typeof entry === "string" ? entry.trim() : "";
|
const raw = typeof entry === "string" ? entry.trim() : "";
|
||||||
if (!raw) continue;
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (raw.startsWith("agent:")) {
|
if (raw.startsWith("agent:")) {
|
||||||
const agentId = raw.slice("agent:".length).trim();
|
const agentId = raw.slice("agent:".length).trim();
|
||||||
if (!agentId) continue;
|
if (agentId) {
|
||||||
tabs.push({
|
normalizedLegacyOrder.push(`agent_${agentId}`);
|
||||||
tabId: `agent_${agentId}`,
|
}
|
||||||
target: { kind: "agent", agentId },
|
|
||||||
createdAt: now,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (raw.startsWith("terminal:")) {
|
if (raw.startsWith("terminal:")) {
|
||||||
const terminalId = raw.slice("terminal:".length).trim();
|
const terminalId = raw.slice("terminal:".length).trim();
|
||||||
if (!terminalId) continue;
|
if (terminalId) {
|
||||||
tabs.push({
|
normalizedLegacyOrder.push(`terminal_${terminalId}`);
|
||||||
tabId: `terminal_${terminalId}`,
|
}
|
||||||
target: { kind: "terminal", terminalId },
|
|
||||||
createdAt: now,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tabs.length > 0) {
|
if (normalizedLegacyOrder.length === 0) {
|
||||||
openTabsByWorkspace[key] = tabs;
|
continue;
|
||||||
}
|
}
|
||||||
|
const existing = tabOrderByWorkspace[key] ?? [];
|
||||||
|
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in legacyFocused) {
|
for (const key in rawFocused) {
|
||||||
const value = legacyFocused[key];
|
const value = rawFocused[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = trimNonEmpty(value);
|
||||||
|
if (normalized) {
|
||||||
|
focusedTabIdByWorkspace[key] = normalized;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
||||||
focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`;
|
focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (value.kind === "terminal" && typeof value.terminalId === "string" && value.terminalId.trim()) {
|
||||||
value.kind === "terminal" &&
|
|
||||||
typeof value.terminalId === "string" &&
|
|
||||||
value.terminalId.trim()
|
|
||||||
) {
|
|
||||||
focusedTabIdByWorkspace[key] = `terminal_${value.terminalId.trim()}`;
|
focusedTabIdByWorkspace[key] = `terminal_${value.terminalId.trim()}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) {
|
||||||
|
focusedTabIdByWorkspace[key] = value.draftId.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openTabsByWorkspace,
|
uiTabsByWorkspace,
|
||||||
|
tabOrderByWorkspace,
|
||||||
focusedTabIdByWorkspace,
|
focusedTabIdByWorkspace,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import type {
|
|||||||
} from './agent/agent-manager.js'
|
} from './agent/agent-manager.js'
|
||||||
import { scheduleAgentMetadataGeneration } from './agent/agent-metadata-generator.js'
|
import { scheduleAgentMetadataGeneration } from './agent/agent-metadata-generator.js'
|
||||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from './agent/agent-projections.js'
|
import { resolveEffectiveThinkingOptionId, toAgentPayload } from './agent/agent-projections.js'
|
||||||
|
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from './agent/agent-title-limits.js'
|
||||||
import {
|
import {
|
||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
@@ -147,6 +148,7 @@ import type pino from 'pino'
|
|||||||
import { resolveClientMessageId } from './client-message-id.js'
|
import { resolveClientMessageId } from './client-message-id.js'
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
const execAsync = promisify(exec)
|
||||||
|
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS)
|
||||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_OPTIONAL_LOCKS: '0',
|
GIT_OPTIONAL_LOCKS: '0',
|
||||||
@@ -159,6 +161,22 @@ const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
|
|||||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024
|
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024
|
||||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048
|
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048
|
||||||
|
|
||||||
|
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||||
|
const firstContentLine = prompt
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0)
|
||||||
|
if (!firstContentLine) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = firstContentLine.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim()
|
||||||
|
return clamped.length > 0 ? clamped : null
|
||||||
|
}
|
||||||
|
|
||||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||||
if (!remoteUrl) {
|
if (!remoteUrl) {
|
||||||
return null
|
return null
|
||||||
@@ -994,7 +1012,7 @@ export class Session {
|
|||||||
|
|
||||||
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
||||||
const storedRecord = await this.agentStorage.get(agent.id)
|
const storedRecord = await this.agentStorage.get(agent.id)
|
||||||
const title = storedRecord?.title ?? null
|
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null
|
||||||
const payload = toAgentPayload(agent, { title })
|
const payload = toAgentPayload(agent, { title })
|
||||||
payload.archivedAt = storedRecord?.archivedAt ?? null
|
payload.archivedAt = storedRecord?.archivedAt ?? null
|
||||||
return payload
|
return payload
|
||||||
@@ -1053,7 +1071,7 @@ export class Session {
|
|||||||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||||||
lastUsage: undefined,
|
lastUsage: undefined,
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
title: record.title ?? null,
|
title: record.title ?? record.config?.title ?? null,
|
||||||
requiresAttention: record.requiresAttention ?? false,
|
requiresAttention: record.requiresAttention ?? false,
|
||||||
attentionReason: record.attentionReason ?? null,
|
attentionReason: record.attentionReason ?? null,
|
||||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||||
@@ -2544,8 +2562,20 @@ export class Session {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const trimmedPrompt = initialPrompt?.trim()
|
||||||
|
const derivedTitle =
|
||||||
|
typeof config.title === 'string' && config.title.trim().length > 0
|
||||||
|
? config.title.trim()
|
||||||
|
: trimmedPrompt
|
||||||
|
? deriveInitialAgentTitle(trimmedPrompt)
|
||||||
|
: null
|
||||||
|
const resolvedConfig: AgentSessionConfig = {
|
||||||
|
...config,
|
||||||
|
...(derivedTitle ? { title: derivedTitle } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
|
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
|
||||||
config,
|
resolvedConfig,
|
||||||
git,
|
git,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
labels
|
labels
|
||||||
@@ -2569,7 +2599,6 @@ export class Session {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedPrompt = initialPrompt?.trim()
|
|
||||||
if (trimmedPrompt) {
|
if (trimmedPrompt) {
|
||||||
scheduleAgentMetadataGeneration({
|
scheduleAgentMetadataGeneration({
|
||||||
agentManager: this.agentManager,
|
agentManager: this.agentManager,
|
||||||
|
|||||||
Reference in New Issue
Block a user