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 { useCallback, useState } from "react";
|
||||
import DraggableFlatList, {
|
||||
NestableDraggableFlatList,
|
||||
type RenderItemParams,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import DraggableFlatList, { type RenderItemParams } from "react-native-draggable-flatlist";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
DraggableListProps,
|
||||
@@ -33,57 +30,63 @@ export function DraggableList<T>({
|
||||
simultaneousGestureRef,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
nestable = false,
|
||||
onDragIntent: onDragIntentProp,
|
||||
onDragRelease: onDragReleaseProp,
|
||||
nestable: _nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const { theme } = useUnistyles();
|
||||
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 handleRenderItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
|
||||
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> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
drag: dragWithLog,
|
||||
isActive,
|
||||
};
|
||||
return renderItem(info);
|
||||
},
|
||||
[renderItem]
|
||||
[keyExtractor, onDragIntentProp, renderItem, testID]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ data: newData }: { data: T[] }) => {
|
||||
console.log("[sidebar-dnd-debug] list onDragEnd", { testID, count: newData.length });
|
||||
setIsDragging(false);
|
||||
onDragEnd(newData);
|
||||
},
|
||||
[onDragEnd]
|
||||
[onDragEnd, testID]
|
||||
);
|
||||
|
||||
const handleDragBegin = useCallback(() => {
|
||||
console.log("[sidebar-dnd-debug] list onDragBegin", { testID });
|
||||
setIsDragging(true);
|
||||
onDragBeginProp?.();
|
||||
}, [onDragBeginProp]);
|
||||
}, [onDragBeginProp, testID]);
|
||||
|
||||
const handleRelease = useCallback(() => {
|
||||
console.log("[sidebar-dnd-debug] list onRelease", { testID });
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
onDragReleaseProp?.();
|
||||
}, [onDragReleaseProp, testID]);
|
||||
|
||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||
const resolvedContainerStyle =
|
||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||
const ListComponent: typeof DraggableFlatList = (nestable
|
||||
? (NestableDraggableFlatList as any)
|
||||
: DraggableFlatList) as any;
|
||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||
const shouldShowRefreshControl = showRefreshControl;
|
||||
|
||||
return (
|
||||
<ListComponent
|
||||
<DraggableFlatList
|
||||
testID={testID}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
@@ -98,11 +101,10 @@ export function DraggableList<T>({
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
|
||||
activationDistance={20}
|
||||
activationDistance={6}
|
||||
onDragBegin={handleDragBegin}
|
||||
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}
|
||||
refreshControl={
|
||||
shouldShowRefreshControl ? (
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface DraggableListProps<T> {
|
||||
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
|
||||
/** Called when a drag gesture begins (before items are reordered) */
|
||||
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
|
||||
* lists coordinated by a shared NestableScrollContainer.
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
StatusBar,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
} from 'react-native'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import {
|
||||
useCallback,
|
||||
@@ -22,9 +22,8 @@ import {
|
||||
} from 'react'
|
||||
import { router, usePathname, useSegments } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight, GripVertical } from 'lucide-react-native'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
@@ -53,9 +52,6 @@ import { useToast } from '@/contexts/toast-context'
|
||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
|
||||
import {
|
||||
shouldOpenContextMenuOnPressOut,
|
||||
} from '@/utils/sidebar-gesture-arbitration'
|
||||
|
||||
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
||||
|
||||
@@ -78,11 +74,6 @@ interface SidebarWorkspaceListProps {
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>
|
||||
}
|
||||
|
||||
interface ContextMenuController {
|
||||
setAnchorRect: (rect: { x: number; y: number; width: number; height: number } | null) => void
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProjectHeaderRowProps {
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
@@ -90,6 +81,7 @@ interface ProjectHeaderRowProps {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}
|
||||
|
||||
@@ -100,9 +92,10 @@ interface WorkspaceRowInnerProps {
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
isArchiving: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
menuController: ContextMenuController | null
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
}
|
||||
|
||||
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
|
||||
@@ -161,159 +154,34 @@ function WorkspaceStatusIndicator({
|
||||
|
||||
function useLongPressDragInteraction(input: {
|
||||
drag: () => void
|
||||
menuController: ContextMenuController | null
|
||||
}) {
|
||||
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(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
return
|
||||
}
|
||||
if (longPressCancelledRef.current) {
|
||||
return
|
||||
}
|
||||
console.log('[sidebar-dnd-debug] long-press armed')
|
||||
void Haptics.selectionAsync().catch(() => {})
|
||||
didLongPressRef.current = true
|
||||
longPressArmedRef.current = true
|
||||
}, [])
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
if (didLongPressCleanupTimerRef.current) {
|
||||
clearTimeout(didLongPressCleanupTimerRef.current)
|
||||
didLongPressCleanupTimerRef.current = null
|
||||
}
|
||||
|
||||
longPressCancelledRef.current = false
|
||||
longPressArmedRef.current = false
|
||||
didStartDragRef.current = false
|
||||
touchStartRef.current = {
|
||||
didLongPressRef.current = false
|
||||
console.log('[sidebar-dnd-debug] press-in', {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
return
|
||||
}
|
||||
|
||||
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])
|
||||
console.log('[sidebar-dnd-debug] press-out no context-menu', {
|
||||
didLongPress: didLongPressRef.current,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
handleLongPress,
|
||||
handlePressIn,
|
||||
handlePressOut,
|
||||
moveMonitorGesture,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,11 +192,11 @@ function ProjectHeaderRow({
|
||||
collapsed,
|
||||
onToggle,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController: useContextMenu(),
|
||||
})
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -339,11 +207,18 @@ function ProjectHeaderRow({
|
||||
onToggle()
|
||||
}, [interaction.didLongPressRef, onToggle])
|
||||
|
||||
const handleDragHandlePressIn = useCallback(() => {
|
||||
console.log('[sidebar-dnd-debug] project drag-handle press-in')
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
drag()
|
||||
}, [drag])
|
||||
|
||||
const trigger = (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
enabledOnMobile
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
hovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
@@ -351,15 +226,10 @@ function ProjectHeaderRow({
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
onLongPress={interaction.handleLongPress}
|
||||
delayLongPress={200}
|
||||
delayLongPress={350}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.projectRowLeft}
|
||||
>
|
||||
<View style={styles.projectRowLeft}>
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} color="#9ca3af" />
|
||||
) : (
|
||||
@@ -378,14 +248,24 @@ function ProjectHeaderRow({
|
||||
{displayName}
|
||||
</Text>
|
||||
</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>
|
||||
)
|
||||
|
||||
return interaction.moveMonitorGesture ? (
|
||||
<GestureDetector gesture={interaction.moveMonitorGesture}>{trigger}</GestureDetector>
|
||||
) : (
|
||||
trigger
|
||||
)
|
||||
return trigger
|
||||
}
|
||||
|
||||
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
|
||||
@@ -411,6 +291,7 @@ function WorkspaceRowInner({
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
@@ -419,7 +300,6 @@ function WorkspaceRowInner({
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
})
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -430,12 +310,17 @@ function WorkspaceRowInner({
|
||||
onPress()
|
||||
}, [interaction.didLongPressRef, onPress])
|
||||
|
||||
const handleDragHandlePressIn = useCallback(() => {
|
||||
console.log('[sidebar-dnd-debug] workspace drag-handle press-in', {
|
||||
workspaceKey: workspace.workspaceKey,
|
||||
})
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
drag()
|
||||
}, [drag, workspace.workspaceKey])
|
||||
|
||||
const rowChildren = (
|
||||
<>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
@@ -454,16 +339,31 @@ function WorkspaceRowInner({
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : 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>
|
||||
</>
|
||||
)
|
||||
|
||||
const trigger = menuController ? (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
enabledOnMobile
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.workspaceRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
@@ -472,7 +372,7 @@ function WorkspaceRowInner({
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
onLongPress={interaction.handleLongPress}
|
||||
delayLongPress={200}
|
||||
delayLongPress={350}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
@@ -482,6 +382,7 @@ function WorkspaceRowInner({
|
||||
disabled={isArchiving}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.workspaceRowSelected,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
@@ -490,18 +391,14 @@ function WorkspaceRowInner({
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
onLongPress={interaction.handleLongPress}
|
||||
delayLongPress={200}
|
||||
delayLongPress={350}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
const content = interaction.moveMonitorGesture ? (
|
||||
<GestureDetector gesture={interaction.moveMonitorGesture}>{trigger}</GestureDetector>
|
||||
) : (
|
||||
trigger
|
||||
)
|
||||
const content = trigger
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer}>
|
||||
@@ -523,6 +420,7 @@ function WorkspaceRowWithMenu({
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
@@ -531,6 +429,7 @@ function WorkspaceRowWithMenu({
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
const toast = useToast()
|
||||
@@ -586,6 +485,7 @@ function WorkspaceRowWithMenu({
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={contextMenu}
|
||||
@@ -612,6 +512,7 @@ function WorkspaceRowPlain({
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
@@ -620,6 +521,7 @@ function WorkspaceRowPlain({
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
return (
|
||||
@@ -630,6 +532,7 @@ function WorkspaceRowPlain({
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={false}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
@@ -644,6 +547,7 @@ function WorkspaceRow({
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
@@ -652,6 +556,7 @@ function WorkspaceRow({
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) {
|
||||
@@ -663,6 +568,7 @@ function WorkspaceRow({
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
@@ -677,6 +583,7 @@ function WorkspaceRow({
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
</ContextMenu>
|
||||
@@ -697,7 +604,10 @@ function ProjectBlock({
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onAnyDragBegin,
|
||||
onAnyDragEnd,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
}: {
|
||||
@@ -714,12 +624,20 @@ function ProjectBlock({
|
||||
onToggleCollapsed: () => void
|
||||
onWorkspacePress?: () => void
|
||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
||||
onAnyDragBegin: () => void
|
||||
onAnyDragEnd: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
useNestable: boolean
|
||||
}) {
|
||||
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 navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||
const isSelected =
|
||||
@@ -741,6 +659,7 @@ function ProjectBlock({
|
||||
navigate(workspaceRoute as any)
|
||||
}}
|
||||
drag={workspaceDrag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={workspaceDragHandleProps}
|
||||
/>
|
||||
)
|
||||
@@ -764,15 +683,23 @@ function ProjectBlock({
|
||||
collapsed={collapsed}
|
||||
onToggle={onToggleCollapsed}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
|
||||
{!collapsed ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
||||
onDragIntent={onAnyDragBegin}
|
||||
onDragBegin={onAnyDragBegin}
|
||||
onDragRelease={onAnyDragEnd}
|
||||
onDragEnd={(workspaces) => {
|
||||
onWorkspaceReorder(project.projectKey, workspaces)
|
||||
onAnyDragEnd()
|
||||
}}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
@@ -795,11 +722,11 @@ export function SidebarWorkspaceList({
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const isNative = Platform.OS !== 'web'
|
||||
const segments = useSegments()
|
||||
const pathname = usePathname()
|
||||
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
const [outerScrollEnabled, setOuterScrollEnabled] = useState(true)
|
||||
const isTauri = getIsTauri()
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||
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(
|
||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||
if (!serverId) {
|
||||
@@ -996,7 +933,7 @@ export function SidebarWorkspaceList({
|
||||
)
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, drag, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||
return (
|
||||
<ProjectBlock
|
||||
project={item}
|
||||
@@ -1012,9 +949,12 @@ export function SidebarWorkspaceList({
|
||||
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
onAnyDragBegin={() => lockOuterScrollForDrag(`workspace:${item.projectKey}`)}
|
||||
onAnyDragEnd={() => unlockOuterScrollForDrag(`workspace:${item.projectKey}`)}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
useNestable={isNative}
|
||||
useNestable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -1022,7 +962,6 @@ export function SidebarWorkspaceList({
|
||||
activeWorkspaceSelection,
|
||||
collapsedProjectKeys,
|
||||
handleWorkspaceReorder,
|
||||
isNative,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
projectIconByProjectKey,
|
||||
@@ -1031,6 +970,8 @@ export function SidebarWorkspaceList({
|
||||
shouldReplaceWorkspaceNavigation,
|
||||
showShortcutBadges,
|
||||
toggleProjectCollapsed,
|
||||
lockOuterScrollForDrag,
|
||||
unlockOuterScrollForDrag,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1040,13 +981,20 @@ export function SidebarWorkspaceList({
|
||||
<Text style={styles.emptyText}>No projects yet</Text>
|
||||
) : (
|
||||
<DraggableList
|
||||
testID="sidebar-project-list"
|
||||
data={projects}
|
||||
keyExtractor={(project) => project.projectKey}
|
||||
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}
|
||||
useDragHandle
|
||||
nestable={isNative}
|
||||
nestable={false}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
@@ -1057,25 +1005,18 @@ export function SidebarWorkspaceList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEnabled={outerScrollEnabled}
|
||||
onScrollBeginDrag={() =>
|
||||
console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled })
|
||||
}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1124,6 +1065,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectRowPressed: {
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1185,6 +1138,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowPressed: {
|
||||
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: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -1226,6 +1191,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
dragHandle: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
dragHandlePressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
workspaceCreatedAtText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -56,6 +56,7 @@ import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||
const DRAFT_AGENT_ID = '__new_agent__'
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map()
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = []
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = 60
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -100,6 +101,22 @@ function getValidMode(provider: AgentProvider | undefined, value: string | undef
|
||||
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 = {
|
||||
serverId?: string
|
||||
provider?: string
|
||||
@@ -981,9 +998,11 @@ function DraftAgentScreenContent({
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
|
||||
const trimmedModel = selectedModel.trim()
|
||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
|
||||
const derivedTitle = deriveInitialAgentTitle(trimmedPrompt)
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(derivedTitle ? { title: derivedTitle } : {}),
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||
|
||||
@@ -193,7 +193,11 @@ export function WorkspaceDesktopTabsRow({
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={tab.label}
|
||||
accessibilityLabel={
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
? "Loading agent title"
|
||||
: tab.label
|
||||
}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
@@ -203,6 +207,14 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<View style={styles.tabIcon}>{icon}</View>
|
||||
{layout.showLabels ? (
|
||||
tab.kind === "agent" && tab.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
@@ -213,6 +225,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -436,6 +449,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tabLabelSkeleton: {
|
||||
width: 96,
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
opacity: 0.9,
|
||||
},
|
||||
tabLabelSkeletonWithCloseButton: {
|
||||
width: 80,
|
||||
},
|
||||
tabLabelWithCloseButton: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
|
||||
@@ -44,13 +44,10 @@ import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceTabsStore,
|
||||
type WorkspaceTabTarget,
|
||||
type WorkspaceTab,
|
||||
} from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
@@ -79,17 +76,17 @@ import {
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
import {
|
||||
canOpenAgentTabFromRoute,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
import {
|
||||
deriveWorkspaceTabModel,
|
||||
} from "@/screens/workspace/workspace-tab-model";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||
const EMPTY_OPEN_FILE_PATHS: string[] = [];
|
||||
const EMPTY_WORKSPACE_TABS: WorkspaceTab[] = [];
|
||||
|
||||
type TabAvailability = "available" | "invalid" | "unknown";
|
||||
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
|
||||
const EMPTY_TAB_ORDER: string[] = [];
|
||||
|
||||
type WorkspaceScreenProps = {
|
||||
serverId: string;
|
||||
@@ -97,41 +94,6 @@ type WorkspaceScreenProps = {
|
||||
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 {
|
||||
if (typeof value !== "string") {
|
||||
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({
|
||||
serverId,
|
||||
@@ -392,11 +303,6 @@ function WorkspaceScreenContent({
|
||||
: null;
|
||||
|
||||
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 desktopFileExplorerOpen = usePanelStore(
|
||||
(state) => state.desktop.fileExplorerOpen
|
||||
@@ -473,14 +379,6 @@ function WorkspaceScreenContent({
|
||||
|
||||
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(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
@@ -490,119 +388,30 @@ function WorkspaceScreenContent({
|
||||
[normalizedServerId, normalizedWorkspaceId]
|
||||
);
|
||||
|
||||
const openTabs = useWorkspaceTabsStore((state) =>
|
||||
const uiTabs = useWorkspaceTabsStore((state) =>
|
||||
persistenceKey
|
||||
? state.openTabsByWorkspace[persistenceKey] ?? EMPTY_WORKSPACE_TABS
|
||||
: EMPTY_WORKSPACE_TABS
|
||||
? state.uiTabsByWorkspace[persistenceKey] ?? EMPTY_UI_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) =>
|
||||
persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
|
||||
);
|
||||
const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
|
||||
const seedWorkspaceTabs = useWorkspaceTabsStore((state) => state.seedWorkspaceTabs);
|
||||
const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
|
||||
const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
|
||||
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
||||
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(() => {
|
||||
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
|
||||
if (!normalized || !persistenceKey) {
|
||||
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_")) {
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
@@ -616,75 +425,49 @@ function WorkspaceScreenContent({
|
||||
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,
|
||||
agentsById,
|
||||
areWorkspaceAgentsHydrated,
|
||||
areWorkspaceTerminalsHydrated,
|
||||
openDraftTab,
|
||||
openOrFocusTab,
|
||||
openTabs,
|
||||
persistenceKey,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
routeTabId,
|
||||
terminalIds,
|
||||
]);
|
||||
|
||||
const activeTabId = useMemo(() => {
|
||||
const fromRoute =
|
||||
typeof routeTabId === "string" ? trimNonEmpty(routeTabId.trim()) : null;
|
||||
if (fromRoute && openTabIdSet.has(fromRoute)) {
|
||||
return fromRoute;
|
||||
}
|
||||
const fromFocus = trimNonEmpty(focusedTabId);
|
||||
if (fromFocus && openTabIdSet.has(fromFocus)) {
|
||||
return fromFocus;
|
||||
}
|
||||
return openTabs[0]?.tabId ?? null;
|
||||
}, [focusedTabId, openTabIdSet, openTabs, routeTabId]);
|
||||
|
||||
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,
|
||||
]);
|
||||
const tabModel = useMemo(
|
||||
() =>
|
||||
deriveWorkspaceTabModel({
|
||||
workspaceAgents,
|
||||
terminals,
|
||||
uiTabs,
|
||||
tabOrder,
|
||||
focusedTabId,
|
||||
routeTabId,
|
||||
}),
|
||||
[focusedTabId, routeTabId, tabOrder, terminals, uiTabs, workspaceAgents]
|
||||
);
|
||||
const activeTabId = tabModel.activeTabId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !persistenceKey) {
|
||||
@@ -711,83 +494,12 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
}, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]);
|
||||
|
||||
const activeTab = useMemo(() => openTabs.find((tab) => tab.tabId === activeTabId) ?? null, [
|
||||
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 activeTab = tabModel.activeTab;
|
||||
|
||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(() => {
|
||||
const next: WorkspaceTabDescriptor[] = [];
|
||||
for (const tab of openTabs) {
|
||||
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 tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||
() => tabModel.tabs.map((tab) => tab.descriptor),
|
||||
[tabModel.tabs]
|
||||
);
|
||||
|
||||
const handleReorderTabs = useCallback(
|
||||
(nextTabs: WorkspaceTabDescriptor[]) => {
|
||||
@@ -851,7 +563,7 @@ function WorkspaceScreenContent({
|
||||
() =>
|
||||
tabs.map((tab) => ({
|
||||
id: tab.key,
|
||||
label: tab.label,
|
||||
label: tab.kind === "agent" && tab.titleState === "loading" ? "Loading..." : tab.label,
|
||||
description: tab.subtitle,
|
||||
})),
|
||||
[tabs]
|
||||
@@ -859,6 +571,9 @@ function WorkspaceScreenContent({
|
||||
|
||||
const activeTabLabel = useMemo(() => {
|
||||
const active = tabs.find((tab) => tab.key === activeTabKey);
|
||||
if (active?.kind === "agent" && active.titleState === "loading") {
|
||||
return "Loading...";
|
||||
}
|
||||
return active?.label ?? "Select tab";
|
||||
}, [activeTabKey, tabs]);
|
||||
|
||||
@@ -1218,15 +933,6 @@ function WorkspaceScreenContent({
|
||||
</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") {
|
||||
return (
|
||||
@@ -1237,19 +943,26 @@ function WorkspaceScreenContent({
|
||||
draftId={target.draftId}
|
||||
onCreated={(agentSnapshot) => {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
replaceWorkspaceTabTarget({
|
||||
const nextAgentTabId = openOrFocusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
target: { kind: "agent", agentId: agentSnapshot.id },
|
||||
});
|
||||
closeWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
target: { kind: "agent", agentId: agentSnapshot.id },
|
||||
});
|
||||
if (nextAgentTabId) {
|
||||
navigateToTabId(nextAgentTabId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === "agent") {
|
||||
return (
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={normalizedServerId}
|
||||
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"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
|
||||
@@ -61,6 +61,10 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works
|
||||
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 {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
@@ -71,39 +75,38 @@ function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
// File tabs are session-only, so stable IDs are still useful for in-session routing.
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
function targetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
function normalizeTabOrder(list: unknown): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
const next: string[] = [];
|
||||
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 left.agentId === right.agentId;
|
||||
return next;
|
||||
}
|
||||
|
||||
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 left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
return [...input.current, input.tabId];
|
||||
}
|
||||
|
||||
type WorkspaceTabsState = {
|
||||
openTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
tabOrderByWorkspace: Record<string, string[]>;
|
||||
focusedTabIdByWorkspace: Record<string, string>;
|
||||
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
||||
seedWorkspaceTabs: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
targets: WorkspaceTabTarget[];
|
||||
focusedTabId?: string | null;
|
||||
}) => void;
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -112,89 +115,24 @@ type WorkspaceTabsState = {
|
||||
focusTab: (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;
|
||||
replaceTabTarget: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
};
|
||||
|
||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
openTabsByWorkspace: {},
|
||||
uiTabsByWorkspace: {},
|
||||
tabOrderByWorkspace: {},
|
||||
focusedTabIdByWorkspace: {},
|
||||
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedDraftId = trimNonEmpty(draftId);
|
||||
if (!key || !normalizedDraftId) {
|
||||
if (!normalizedDraftId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = { kind: "draft", draftId: normalizedDraftId };
|
||||
const tabId = buildDeterministicTabId(target);
|
||||
const now = Date.now();
|
||||
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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return get().openOrFocusTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "draft", draftId: normalizedDraftId },
|
||||
});
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
@@ -203,29 +141,50 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
if (!key || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = buildDeterministicTabId(normalizedTarget);
|
||||
const now = Date.now();
|
||||
|
||||
set((state) => {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
const existing = current.find((tab) => tab.tabId === tabId);
|
||||
if (existing) {
|
||||
if (state.focusedTabIdByWorkspace[key] === tabId) {
|
||||
return state;
|
||||
}
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId });
|
||||
|
||||
if (!isUiTarget(normalizedTarget)) {
|
||||
return {
|
||||
...state,
|
||||
tabOrderByWorkspace:
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[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 {
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
||||
uiTabsByWorkspace:
|
||||
nextTabs === currentTabs
|
||||
? state.uiTabsByWorkspace
|
||||
: { ...state.uiTabsByWorkspace, [key]: nextTabs },
|
||||
tabOrderByWorkspace:
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return tabId;
|
||||
},
|
||||
focusTab: ({ serverId, workspaceId, tabId }) => {
|
||||
@@ -253,25 +212,36 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
if (!key || !normalizedTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const currentTabs = state.openTabsByWorkspace[key] ?? [];
|
||||
if (currentTabs.length === 0 || !currentTabs.some((tab) => tab.tabId === normalizedTabId)) {
|
||||
return state;
|
||||
}
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
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
|
||||
? (() => {
|
||||
const { [key]: _removed, ...rest } = state.openTabsByWorkspace;
|
||||
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
|
||||
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 nextFocused =
|
||||
currentFocused !== normalizedTabId
|
||||
? currentFocused
|
||||
: nextTabs[nextTabs.length - 1]?.tabId ?? null;
|
||||
currentFocused !== normalizedTabId ? currentFocused : nextOrder[nextOrder.length - 1] ?? null;
|
||||
const nextFocusedByWorkspace = (() => {
|
||||
if (!nextFocused) {
|
||||
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
||||
@@ -280,8 +250,18 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
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 {
|
||||
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||
};
|
||||
});
|
||||
@@ -291,75 +271,29 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
if (!key) {
|
||||
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) => {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
const idx = current.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
const current = state.tabOrderByWorkspace[key] ?? [];
|
||||
if (current.length === normalized.length) {
|
||||
let same = true;
|
||||
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 {
|
||||
...state,
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||
tabOrderByWorkspace: {
|
||||
...state.tabOrderByWorkspace,
|
||||
[key]: normalized,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -368,99 +302,186 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
return get().openTabsByWorkspace[key] ?? [];
|
||||
return get().uiTabsByWorkspace[key] ?? [];
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "workspace-tabs-state",
|
||||
version: 3,
|
||||
version: 4,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => {
|
||||
const nextOpenTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.openTabsByWorkspace) {
|
||||
const tabs = state.openTabsByWorkspace[key] ?? [];
|
||||
const persistedTabs = tabs.filter((tab) => tab.target.kind !== "file");
|
||||
if (persistedTabs.length > 0) {
|
||||
nextOpenTabsByWorkspace[key] = persistedTabs;
|
||||
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? []).filter((tab) => isUiTarget(tab.target));
|
||||
if (tabs.length > 0) {
|
||||
nextUiTabsByWorkspace[key] = tabs;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
||||
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
|
||||
} as WorkspaceTabsState;
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
|
||||
};
|
||||
},
|
||||
migrate: (persistedState) => {
|
||||
// Previous versions stored tab order + last focused tab by workspace using agent/terminal targets.
|
||||
const legacy = persistedState as
|
||||
| {
|
||||
version?: number;
|
||||
state?: any;
|
||||
lastFocusedTabByWorkspace?: Record<string, any>;
|
||||
openTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
||||
uiTabsByWorkspace?: Record<string, WorkspaceTab[]>;
|
||||
focusedTabIdByWorkspace?: Record<string, string>;
|
||||
tabOrderByWorkspace?: Record<string, string[]>;
|
||||
lastFocusedTabByWorkspace?: Record<string, any>;
|
||||
tabOrderLegacyByWorkspace?: Record<string, string[]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
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> = {};
|
||||
|
||||
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) {
|
||||
const list = legacyOrder[key];
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const now = Date.now();
|
||||
const tabs: WorkspaceTab[] = [];
|
||||
const normalizedLegacyOrder: string[] = [];
|
||||
for (const entry of list) {
|
||||
const raw = typeof entry === "string" ? entry.trim() : "";
|
||||
if (!raw) continue;
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith("agent:")) {
|
||||
const agentId = raw.slice("agent:".length).trim();
|
||||
if (!agentId) continue;
|
||||
tabs.push({
|
||||
tabId: `agent_${agentId}`,
|
||||
target: { kind: "agent", agentId },
|
||||
createdAt: now,
|
||||
});
|
||||
if (agentId) {
|
||||
normalizedLegacyOrder.push(`agent_${agentId}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith("terminal:")) {
|
||||
const terminalId = raw.slice("terminal:".length).trim();
|
||||
if (!terminalId) continue;
|
||||
tabs.push({
|
||||
tabId: `terminal_${terminalId}`,
|
||||
target: { kind: "terminal", terminalId },
|
||||
createdAt: now,
|
||||
});
|
||||
if (terminalId) {
|
||||
normalizedLegacyOrder.push(`terminal_${terminalId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tabs.length > 0) {
|
||||
openTabsByWorkspace[key] = tabs;
|
||||
if (normalizedLegacyOrder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = tabOrderByWorkspace[key] ?? [];
|
||||
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
|
||||
}
|
||||
|
||||
for (const key in legacyFocused) {
|
||||
const value = legacyFocused[key];
|
||||
for (const key in rawFocused) {
|
||||
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") {
|
||||
continue;
|
||||
}
|
||||
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
||||
focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
value.kind === "terminal" &&
|
||||
typeof value.terminalId === "string" &&
|
||||
value.terminalId.trim()
|
||||
) {
|
||||
if (value.kind === "terminal" && typeof value.terminalId === "string" && 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 {
|
||||
openTabsByWorkspace,
|
||||
uiTabsByWorkspace,
|
||||
tabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ import type {
|
||||
} from './agent/agent-manager.js'
|
||||
import { scheduleAgentMetadataGeneration } from './agent/agent-metadata-generator.js'
|
||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from './agent/agent-projections.js'
|
||||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from './agent/agent-title-limits.js'
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -147,6 +148,7 @@ import type pino from 'pino'
|
||||
import { resolveClientMessageId } from './client-message-id.js'
|
||||
|
||||
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 = {
|
||||
...process.env,
|
||||
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_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 {
|
||||
if (!remoteUrl) {
|
||||
return null
|
||||
@@ -994,7 +1012,7 @@ export class Session {
|
||||
|
||||
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
||||
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 })
|
||||
payload.archivedAt = storedRecord?.archivedAt ?? null
|
||||
return payload
|
||||
@@ -1053,7 +1071,7 @@ export class Session {
|
||||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||||
lastUsage: undefined,
|
||||
lastError: undefined,
|
||||
title: record.title ?? null,
|
||||
title: record.title ?? record.config?.title ?? null,
|
||||
requiresAttention: record.requiresAttention ?? false,
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
@@ -2544,8 +2562,20 @@ export class Session {
|
||||
)
|
||||
|
||||
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(
|
||||
config,
|
||||
resolvedConfig,
|
||||
git,
|
||||
worktreeName,
|
||||
labels
|
||||
@@ -2569,7 +2599,6 @@ export class Session {
|
||||
})
|
||||
}
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim()
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
|
||||
Reference in New Issue
Block a user