feat: add explicit drag handles to sidebar lists and refactor workspace tab model

This commit is contained in:
Mohamed Boudra
2026-03-04 11:57:22 +07:00
parent 952ff0dd72
commit b5b4e60916
11 changed files with 984 additions and 797 deletions
@@ -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;
+241 -220
View File
@@ -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,
};
},
+33 -4
View File
@@ -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,