diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 5849dcaaf..9654baa4f 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -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({ simultaneousGestureRef, waitFor, onDragBegin: onDragBeginProp, - nestable = false, + onDragIntent: onDragIntentProp, + onDragRelease: onDragReleaseProp, + nestable: _nestable = false, }: DraggableListProps) { 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) => { 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 = { 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 ( - ({ 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 ? ( diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index ea5018893..b2b263261 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -52,6 +52,10 @@ export interface DraggableListProps { waitFor?: MutableRefObject | MutableRefObject[]; /** 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. diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 1691f8625..c0842534e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 } -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 | 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 | 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 = ( [ 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}`} > - + {collapsed ? ( ) : ( @@ -378,14 +248,24 @@ function ProjectHeaderRow({ {displayName} + (event as any).stopPropagation?.()} + style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]} + testID={`sidebar-project-drag-handle-${project.projectKey}`} + > + + ) - return interaction.moveMonitorGesture ? ( - {trigger} - ) : ( - 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 = ( <> @@ -454,16 +339,31 @@ function WorkspaceRowInner({ {shortcutNumber} ) : null} + (event as any).stopPropagation?.()} + style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]} + testID={`sidebar-workspace-drag-handle-${workspace.workspaceKey}`} + > + + ) const trigger = menuController ? ( [ styles.workspaceRow, + 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} ) - const content = interaction.moveMonitorGesture ? ( - {trigger} - ) : ( - trigger - ) + const content = trigger return ( @@ -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} /> @@ -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) => { + ({ + item, + drag: workspaceDrag, + isActive, + dragHandleProps: workspaceDragHandleProps, + }: DraggableRenderItemInfo) => { 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 ? ( 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>(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) => { + ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => { return ( 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({ No projects yet ) : ( 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 ( - {isNative ? ( - - {content} - - ) : ( - - {content} - - )} + + console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled }) + } + testID="sidebar-project-workspace-list-scroll" + > + {content} + ) } @@ -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, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 4f643442a..b4eb65082 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -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 } : {}), diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index f22f01747..724b9ee2b 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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 + } > {icon} {layout.showLabels ? ( + tab.kind === "agent" && tab.titleState === "loading" ? ( + + ) : ( {tab.label} + ) ) : null} @@ -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, }, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d90584c95..37b9c2498 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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["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(); - for (const tab of input.tabs) { - byKey.set(tab.key, tab); - } - - const used = new Set(); - 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; - terminalIds: Set; -}): 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(); - 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(() => { - 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( + () => 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({ ); } - if (activeTabAvailability === "invalid") { - return ( - - - This tab is no longer available. It will be removed from your workspace. - - - ); - } 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 ( { + 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); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts new file mode 100644 index 000000000..ed0e75524 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -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(); + + 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(); + 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, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-tabs-types.ts b/packages/app/src/screens/workspace/workspace-tabs-types.ts index 1ccc06a65..77c9c10ef 100644 --- a/packages/app/src/screens/workspace/workspace-tabs-types.ts +++ b/packages/app/src/screens/workspace/workspace-tabs-types.ts @@ -17,6 +17,7 @@ export type WorkspaceTabDescriptor = provider: Agent["provider"]; label: string; subtitle: string; + titleState: "ready" | "loading"; } | { key: string; diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index c8bdfeb11..da9edadea 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -61,6 +61,10 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works return null; } +function isUiTarget(target: WorkspaceTabTarget): target is Extract { + 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(); + 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; + uiTabsByWorkspace: Record; + tabOrderByWorkspace: Record; focusedTabIdByWorkspace: Record; 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()( 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(); - 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()( 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()( 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()( 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()( 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(); - 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()( 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 = {}; - 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 = {}; + 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 = {}; + for (const key in state.tabOrderByWorkspace) { + const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); + if (order.length > 0) { + nextTabOrderByWorkspace[key] = order; + } + } + + const nextFocusedTabIdByWorkspace: Record = {}; + 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; + openTabsByWorkspace?: Record; + uiTabsByWorkspace?: Record; + focusedTabIdByWorkspace?: Record; tabOrderByWorkspace?: Record; + lastFocusedTabByWorkspace?: Record; + tabOrderLegacyByWorkspace?: Record; } | undefined; const rawState = (legacy as any)?.state ?? legacy ?? {}; - const legacyFocused = rawState.lastFocusedTabByWorkspace ?? {}; - const legacyOrder = rawState.tabOrderByWorkspace ?? {}; - const openTabsByWorkspace: Record = {}; + 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 = {}; + const tabOrderByWorkspace: Record = {}; const focusedTabIdByWorkspace: Record = {}; + for (const key in rawUiTabsByWorkspace) { + const entries = Array.isArray(rawUiTabsByWorkspace[key]) ? rawUiTabsByWorkspace[key] : []; + const nextUiTabs: WorkspaceTab[] = []; + const orderFromTabs: string[] = []; + const usedOrder = new Set(); + + 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, }; }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d542e2538..2ea76c5b8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { 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,