diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e9575c8d5..8e0840052 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -23,7 +23,7 @@ import { getHostRuntimeStore, useHosts, useHostMutations, - useHostRuntimeSession, + useHostRuntimeClient, } from "@/runtime/host-runtime"; import { SessionProvider } from "@/contexts/session-context"; import type { HostProfile } from "@/types/host-connection"; @@ -181,7 +181,7 @@ function PushNotificationRouter() { } function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { - const { client } = useHostRuntimeSession(daemon.serverId); + const client = useHostRuntimeClient(daemon.serverId); if (!client) { return null; @@ -257,6 +257,9 @@ function QueryProvider({ children }: { children: ReactNode }) { return {children}; } +const rowStyle = { flex: 1, flexDirection: "row" } as const; +const flexStyle = { flex: 1 } as const; + interface AppContainerProps { children: ReactNode; selectedAgentId?: string; @@ -270,23 +273,12 @@ function AppContainer({ }: AppContainerProps) { const { theme } = useUnistyles(); const daemons = useHosts(); - const mobileView = usePanelStore((state) => state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - const openAgentList = usePanelStore((state) => state.openAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); - const horizontalScroll = useHorizontalScrollOptional(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; - const isOpen = chromeEnabled - ? isMobile - ? mobileView === "agent-list" - : desktopAgentListOpen - : false; - const openGestureEnabled = - chromeEnabled && isMobile && mobileView === "agent"; useKeyboardShortcuts({ enabled: chromeEnabled, @@ -295,6 +287,50 @@ function AppContainer({ selectedAgentId, toggleFileExplorer, }); + + const containerStyle = useMemo( + () => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }), + [theme.colors.surface0] + ); + + const content = ( + + + {!isMobile && chromeEnabled && } + + {children} + + + {isMobile && chromeEnabled && } + + + + + + + ); + + if (!isMobile) { + return content; + } + + return ( + + {content} + + ); +} + +function MobileGestureWrapper({ + children, + chromeEnabled, +}: { + children: ReactNode; + chromeEnabled: boolean; +}) { + const mobileView = usePanelStore((state) => state.mobileView); + const openAgentList = usePanelStore((state) => state.openAgentList); + const horizontalScroll = useHorizontalScrollOptional(); const { translateX, backdropOpacity, @@ -303,18 +339,14 @@ function AppContainer({ animateToClose, isGesturing, } = useSidebarAnimation(); - - // Track initial touch position for manual activation const touchStartX = useSharedValue(0); + const openGestureEnabled = chromeEnabled && mobileView === "agent"; - // Open gesture: swipe right from anywhere to open sidebar (interactive drag) - // If any horizontal scroll is scrolled right, let the scroll view handle the gesture first const openGesture = useMemo( () => Gesture.Pan() .enabled(openGestureEnabled) .manualActivation(true) - // Fail if 10px vertical movement happens first (allow vertical scroll) .failOffsetY([-10, 10]) .onTouchesDown((event) => { const touch = event.changedTouches[0]; @@ -328,13 +360,11 @@ function AppContainer({ const deltaX = touch.absoluteX - touchStartX.value; - // If horizontal scroll is scrolled right, fail so ScrollView handles it if (horizontalScroll?.isAnyScrolledRight.value) { stateManager.fail(); return; } - // Activate after 15px rightward movement if (deltaX > 15) { stateManager.activate(); } @@ -343,7 +373,6 @@ function AppContainer({ isGesturing.value = true; }) .onUpdate((event) => { - // Start from closed position (-windowWidth) and move towards 0 const newTranslateX = Math.min(0, -windowWidth + event.translationX); translateX.value = newTranslateX; backdropOpacity.value = interpolate( @@ -355,7 +384,6 @@ function AppContainer({ }) .onEnd((event) => { isGesturing.value = false; - // Open if dragged more than 1/3 of sidebar or fast swipe const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; if (shouldOpen) { animateToOpen(); @@ -375,37 +403,15 @@ function AppContainer({ animateToOpen, animateToClose, openAgentList, - mobileView, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX, ] ); - const content = ( - - - {!isMobile && chromeEnabled && } - - {children} - - - {isMobile && chromeEnabled && } - - - - - - - ); - - if (!isMobile) { - return content; - } - return ( - {content} + {children} ); } @@ -435,6 +441,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { + {children} ); @@ -485,7 +492,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const pathname = usePathname(); const params = useGlobalSearchParams<{ open?: string | string[] }>(); const hosts = useHosts(); - useFaviconStatus(); const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const shouldShowAppChrome = activeServerId !== null; @@ -525,6 +531,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) { ); } +function FaviconStatusSync() { + useFaviconStatus(); + return null; +} + function NavigationActiveWorkspaceObserver() { const navigationRef = useNavigationContainerRef(); @@ -625,10 +636,13 @@ export default function RootLayout() { > - + diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 4b7fb7401..54ef75896 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildHostRootRoute, buildHostWorkspaceAgentRoute, @@ -16,7 +16,8 @@ export default function HostAgentReadyRoute() { const redirectedRef = useRef(false); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const agentId = typeof params.agentId === "string" ? params.agentId : ""; - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const agentCwd = useSessionStore((state) => { if (!serverId || !agentId) { return null; diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 7ccc059c9..b0c922ec4 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -28,7 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Shortcut } from '@/components/ui/shortcut' import { Autocomplete } from '@/components/ui/autocomplete' import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' -import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { deleteAttachments, persistAttachmentFromBlob, @@ -100,14 +100,16 @@ export function AgentInputArea({ const insets = useSafeAreaInsets() const isScreenFocused = useIsFocused() - const { client, isConnected, snapshot } = useHostRuntimeSession(serverId) + const client = useHostRuntimeClient(serverId) + const isConnected = useHostRuntimeIsConnected(serverId) + const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId) const toast = useToast() const voice = useVoiceOptional() const isDictationReady = isConnected && - (snapshot?.agentDirectoryStatus === 'ready' || - snapshot?.agentDirectoryStatus === 'revalidating' || - snapshot?.agentDirectoryStatus === 'error_after_ready') + (agentDirectoryStatus === 'ready' || + agentDirectoryStatus === 'revalidating' || + agentDirectoryStatus === 'error_after_ready') const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 17be8b537..fd151a0c0 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,4 +1,15 @@ -import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' +import { + memo, + useCallback, + useMemo, + useState, + useEffect, + useRef, + useSyncExternalStore, + type Dispatch, + type RefObject, + type SetStateAction, +} from 'react' import { View, Pressable, Text, Platform } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import Animated, { @@ -18,7 +29,7 @@ import { usePanelStore } from '@/stores/panel-store' import { SidebarWorkspaceList } from './sidebar-workspace-list' import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton' import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model' -import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list' +import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window' import { Combobox } from '@/components/ui/combobox' @@ -34,12 +45,58 @@ import { import { useOpenProjectPicker } from '@/hooks/use-open-project-picker' const DESKTOP_SIDEBAR_WIDTH = 320 +type SidebarShortcutModel = ReturnType +type SidebarTheme = ReturnType['theme'] interface LeftSidebarProps { selectedAgentId?: string } -export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { +interface HostOption { + id: string + label: string + description: string +} + +interface SidebarSharedProps { + theme: SidebarTheme + activeServerId: string | null + activeHostLabel: string + activeHostStatusColor: string + hostOptions: HostOption[] + hostTriggerRef: RefObject + isHostPickerOpen: boolean + setIsHostPickerOpen: Dispatch> + projects: SidebarProjectEntry[] + isInitialLoad: boolean + isRevalidating: boolean + isManualRefresh: boolean + collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys'] + shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey'] + toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed'] + setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed'] + handleRefresh: () => void + handleHostSelect: (nextServerId: string) => void + handleOpenProject: () => void + handleSettings: () => void +} + +interface MobileSidebarProps extends SidebarSharedProps { + insetsTop: number + insetsBottom: number + isOpen: boolean + closeToAgent: () => void + handleViewMoreNavigate: () => void +} + +interface DesktopSidebarProps extends SidebarSharedProps { + isOpen: boolean + handleViewMore: () => void +} + +export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { + void _selectedAgentId + const { theme } = useUnistyles() const insets = useSafeAreaInsets() const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' @@ -109,10 +166,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr })), [daemons, runtime, runtimeConnectionStatusSignature] ) - const hostTriggerRef = useRef(null) + const hostTriggerRef = useRef(null) const [isHostPickerOpen, setIsHostPickerOpen] = useState(false) - // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ @@ -121,21 +177,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr }) const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } = useSidebarShortcutModel(projects) - const { - translateX, - backdropOpacity, - windowWidth, - animateToOpen, - animateToClose, - isGesturing, - closeGestureRef, - } = useSidebarAnimation() - const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers() - const trafficLightPadding = useTrafficLightPadding() - const closeTouchStartX = useSharedValue(0) - const closeTouchStartY = useSharedValue(0) - // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false) const handleRefresh = useCallback(() => { @@ -143,17 +185,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr refreshAll() }, [refreshAll]) - // Reset manual refresh flag when revalidation completes useEffect(() => { if (!isRevalidating && isManualRefresh) { setIsManualRefresh(false) } }, [isRevalidating, isManualRefresh]) - const handleClose = useCallback(() => { - closeToAgent() - }, [closeToAgent]) - const openProjectPicker = useOpenProjectPicker(activeServerId) const handleOpenProjectMobile = useCallback(() => { @@ -165,7 +202,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr void openProjectPicker() }, [openProjectPicker]) - // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { if (!activeServerId) { return @@ -174,7 +210,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr router.push(buildHostSettingsRoute(activeServerId) as any) }, [activeServerId, closeToAgent]) - // Desktop: just navigate, don't close const handleSettingsDesktop = useCallback(() => { if (!activeServerId) { return @@ -182,17 +217,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr router.push(buildHostSettingsRoute(activeServerId) as any) }, [activeServerId]) - const handleViewMore = useCallback(() => { + const handleViewMoreNavigate = useCallback(() => { if (!activeServerId) { return } - if (isMobile) { - translateX.value = -windowWidth - backdropOpacity.value = 0 - closeToAgent() - } router.push(buildHostAgentsRoute(activeServerId) as any) - }, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]) + }, [activeServerId]) const handleHostSelect = useCallback( (nextServerId: string) => { @@ -206,77 +236,201 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr [pathname] ) - // Close gesture (swipe left to close when sidebar is open) - const closeGesture = Gesture.Pan() - .withRef(closeGestureRef) - .enabled(isOpen) - // Use manual activation so child views keep touch streams unless we detect - // an intentional left-swipe close (mirrors explorer-sidebar pattern). - .manualActivation(true) - .onTouchesDown((event) => { - const touch = event.changedTouches[0] - if (!touch) { - return - } - closeTouchStartX.value = touch.absoluteX - closeTouchStartY.value = touch.absoluteY - }) - .onTouchesMove((event, stateManager) => { - const touch = event.changedTouches[0] - if (!touch || event.numberOfTouches !== 1) { - stateManager.fail() - return - } + const sharedProps = { + theme, + activeServerId, + activeHostLabel, + activeHostStatusColor, + hostOptions, + hostTriggerRef, + isHostPickerOpen, + setIsHostPickerOpen, + projects, + isInitialLoad, + isRevalidating, + isManualRefresh, + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + toggleProjectCollapsed, + setProjectCollapsed, + handleRefresh, + handleHostSelect, + } - const deltaX = touch.absoluteX - closeTouchStartX.value - const deltaY = touch.absoluteY - closeTouchStartY.value - const absDeltaX = Math.abs(deltaX) - const absDeltaY = Math.abs(deltaY) + if (isMobile) { + return ( + + ) + } - // Fail quickly on clear rightward or vertical intent so child views keep control. - if (deltaX >= 10) { - stateManager.fail() - return - } - if (absDeltaY > 10 && absDeltaY > absDeltaX) { - stateManager.fail() - return - } + return ( + + ) +}) - // Activate only on intentional leftward movement. - if (deltaX <= -15 && absDeltaX > absDeltaY) { - stateManager.activate() - } - }) - .onStart(() => { - isGesturing.value = true - }) - .onUpdate((event) => { - if (!isMobile) return - // Only allow swiping left (closing) - const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) - translateX.value = newTranslateX - backdropOpacity.value = interpolate( - newTranslateX, - [-windowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ) - }) - .onEnd((event) => { - isGesturing.value = false - if (!isMobile) return - const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 - if (shouldClose) { - animateToClose() - runOnJS(handleClose)() - } else { - animateToOpen() - } - }) - .onFinalize(() => { - isGesturing.value = false - }) +function MobileSidebar({ + theme, + activeServerId, + activeHostLabel, + activeHostStatusColor, + hostOptions, + hostTriggerRef, + isHostPickerOpen, + setIsHostPickerOpen, + projects, + isInitialLoad, + isRevalidating, + isManualRefresh, + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + toggleProjectCollapsed, + setProjectCollapsed, + handleRefresh, + handleHostSelect, + handleOpenProject, + handleSettings, + insetsTop, + insetsBottom, + isOpen, + closeToAgent, + handleViewMoreNavigate, +}: MobileSidebarProps) { + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + closeGestureRef, + } = useSidebarAnimation() + const closeTouchStartX = useSharedValue(0) + const closeTouchStartY = useSharedValue(0) + + const handleClose = useCallback(() => { + closeToAgent() + }, [closeToAgent]) + + const handleViewMore = useCallback(() => { + if (!activeServerId) { + return + } + translateX.value = -windowWidth + backdropOpacity.value = 0 + closeToAgent() + handleViewMoreNavigate() + }, [ + activeServerId, + backdropOpacity, + closeToAgent, + handleViewMoreNavigate, + translateX, + windowWidth, + ]) + + const closeGesture = useMemo( + () => + Gesture.Pan() + .withRef(closeGestureRef) + .enabled(isOpen) + .manualActivation(true) + .onTouchesDown((event) => { + const touch = event.changedTouches[0] + if (!touch) { + return + } + closeTouchStartX.value = touch.absoluteX + closeTouchStartY.value = touch.absoluteY + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0] + if (!touch || event.numberOfTouches !== 1) { + stateManager.fail() + return + } + + const deltaX = touch.absoluteX - closeTouchStartX.value + const deltaY = touch.absoluteY - closeTouchStartY.value + const absDeltaX = Math.abs(deltaX) + const absDeltaY = Math.abs(deltaY) + + if (deltaX >= 10) { + stateManager.fail() + return + } + if (absDeltaY > 10 && absDeltaY > absDeltaX) { + stateManager.fail() + return + } + if (deltaX <= -15 && absDeltaX > absDeltaY) { + stateManager.activate() + } + }) + .onStart(() => { + isGesturing.value = true + }) + .onUpdate((event) => { + const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) + translateX.value = newTranslateX + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ) + }) + .onEnd((event) => { + isGesturing.value = false + const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 + if (shouldClose) { + animateToClose() + runOnJS(handleClose)() + } else { + animateToOpen() + } + }) + .onFinalize(() => { + isGesturing.value = false + }), + [ + isOpen, + closeGestureRef, + closeTouchStartX, + closeTouchStartY, + isGesturing, + windowWidth, + translateX, + backdropOpacity, + animateToClose, + animateToOpen, + handleClose, + ] + ) + + const mobileSidebarInsetStyle = useMemo( + () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), + [windowWidth, insetsTop, insetsBottom] + ) + + const hostStatusDotStyle = useMemo( + () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], + [activeHostStatusColor] + ) const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], @@ -287,160 +441,174 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none', })) - // Render mobile sidebar - // On web, keep the overlay interactive only while the sidebar is open. - // This preserves swipe/scroll behavior without blocking taps when closed. const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none' - if (isMobile) { - return ( - - {/* Backdrop */} - - - - - - - {/* Header */} - - - - {({ hovered }) => ( - <> - - - Sessions - - - )} - - - + return ( + + + + - {/* Middle: scrollable project/workspace tree */} - {isInitialLoad ? ( - - ) : ( - - )} - - {/* Footer */} - - - [ - styles.hostTrigger, - hovered && styles.hostTriggerHovered, - ]} - onPress={() => setIsHostPickerOpen(true)} - disabled={hostOptions.length === 0} - > - - - {activeHostLabel} - - - - - - - - {({ hovered }) => ( - - )} - - - - - Add project - - - - - - {({ hovered }) => ( - + + + + + + {({ hovered }) => ( + <> + - )} - - - + + Sessions + + + )} + - - - - ) - } - // Desktop: no edge swipe, just show/hide based on isOpen + {isInitialLoad ? ( + + ) : ( + + )} + + + + [ + styles.hostTrigger, + hovered && styles.hostTriggerHovered, + ]} + onPress={() => setIsHostPickerOpen(true)} + disabled={hostOptions.length === 0} + > + + + {activeHostLabel} + + + + + + + + {({ hovered }) => ( + + )} + + + + + Add project + + + + + + {({ hovered }) => ( + + )} + + + + + + + + + ) +} + +function DesktopSidebar({ + theme, + activeServerId, + activeHostLabel, + activeHostStatusColor, + hostOptions, + hostTriggerRef, + isHostPickerOpen, + setIsHostPickerOpen, + projects, + isInitialLoad, + isRevalidating, + isManualRefresh, + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + toggleProjectCollapsed, + setProjectCollapsed, + handleRefresh, + handleHostSelect, + handleOpenProject, + handleSettings, + isOpen, + handleViewMore, +}: DesktopSidebarProps) { + const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers() + const trafficLightPadding = useTrafficLightPadding() + const hostStatusDotStyle = useMemo( + () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], + [activeHostStatusColor] + ) + if (!isOpen) { return null } @@ -474,7 +642,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr - {/* Middle: scrollable project/workspace tree */} {isInitialLoad ? ( ) : ( @@ -490,7 +657,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr /> )} - {/* Footer */} setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > - + {activeHostLabel} @@ -519,7 +685,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr accessible accessibilityLabel="Add project" accessibilityRole="button" - onPress={handleOpenProjectDesktop} + onPress={handleOpenProject} > {({ hovered }) => ( {({ hovered }) => ( serverId ? state.sessions[serverId]?.workspaces : undefined ); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5c2ff5806..28329aa68 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -25,7 +25,7 @@ import { navigateToWorkspace } from '@/hooks/use-workspace-navigation' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' import * as Clipboard from 'expo-clipboard' -import { Archive, ChevronDown, ChevronRight, Copy, MoreVertical, Plus } from 'lucide-react-native' +import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, Monitor, MoreVertical, Plus } from 'lucide-react-native' import { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' @@ -160,18 +160,26 @@ function resolveStatusDotColor(input: { function WorkspaceStatusIndicator({ bucket, + workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry['statusBucket'] + workspaceKind: SidebarWorkspaceEntry['workspaceKind'] loading?: boolean }) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) - const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done' + const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done' - if (shouldRenderIdlePlaceholder) { - return null + if (isIdle) { + const KindIcon = workspaceKind === 'local_checkout' ? Monitor : workspaceKind === 'worktree' ? FolderGit2 : null + if (!KindIcon) return null + return ( + + + + ) } return ( @@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({ ) : shouldShowSyncedLoader ? ( + ) : bucket === 'needs_input' ? ( + + ) : bucket === 'attention' ? ( + ) : ( )} @@ -213,7 +225,7 @@ function ProjectLeadingVisual({ {showChevron && chevron !== null ? ( ) : shouldShowWorkspaceStatus && activeWorkspace ? ( - + ) : iconDataUri ? ( ) : ( @@ -686,6 +698,7 @@ function WorkspaceRowInner({ > ({ workspaceRowLeft: { flexDirection: 'row', alignItems: 'center', - gap: theme.spacing[1], + gap: theme.spacing[2], flex: 1, minWidth: 0, }, @@ -1938,7 +1951,7 @@ const styles = StyleSheet.create((theme) => ({ position: 'relative', }, workspaceStatusDot: { - width: 11, + width: 14, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 75c56ebd0..23215f177 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -72,6 +72,7 @@ interface SplitContainerProps { newTabAgentOptionId?: "__new_tab_agent__"; buildPaneContentModel: (input: { paneId: string; + isPaneFocused: boolean; tab: WorkspaceTabDescriptor; }) => WorkspacePaneContentModel; onFocusPane: (paneId: string) => void; @@ -751,6 +752,7 @@ function SplitPaneView({ activeTabDescriptor ? buildPaneContentModel({ paneId: pane.id, + isPaneFocused: isFocused, tab: activeTabDescriptor, }) : null, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index f106b17e5..ada1657a8 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -19,7 +19,7 @@ import Svg, { import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { hasPendingTerminalModifiers, @@ -150,7 +150,8 @@ export function TerminalPane({ }); const queryClient = useQueryClient(); - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 59203e697..eed2a16a0 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; import { useWindowDimensions } from "react-native"; import { useSharedValue, @@ -78,7 +78,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) } }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); - const animateToOpen = () => { + const animateToOpen = useCallback(() => { "worklet"; translateX.value = withTiming(0, { duration: ANIMATION_DURATION, @@ -88,9 +88,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }; + }, [translateX, backdropOpacity]); - const animateToClose = () => { + const animateToClose = useCallback(() => { "worklet"; translateX.value = withTiming(-windowWidth, { duration: ANIMATION_DURATION, @@ -100,20 +100,23 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }; + }, [translateX, backdropOpacity, windowWidth]); + + const value = useMemo( + () => ({ + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + closeGestureRef, + }), + [translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef] + ); return ( - + {children} ); diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index eac27074c..491e0fe1a 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -5,7 +5,7 @@ import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-comm import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils' import { useAutocomplete } from './use-autocomplete' import { useSessionStore } from '@/stores/session-store' -import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { applyFileMentionReplacement, findActiveFileMention, @@ -138,7 +138,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut return agentCwd.trim() }, [agentCwd, isDraftContext, queryDraftConfig]) - const { client, isConnected } = useHostRuntimeSession(serverId) + const client = useHostRuntimeClient(serverId) + const isConnected = useHostRuntimeIsConnected(serverId) const mode: 'command' | 'file' | null = showFileAutocomplete ? 'file' diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index e77ab7d34..63c16e5d0 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute @@ -48,7 +48,8 @@ export function useAgentCommandsQuery({ enabled = true, draftConfig, }: UseAgentCommandsQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ queryKey: commandsQueryKey(serverId, agentId, draftConfig), diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 82c68a532..829ecfa8b 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -10,7 +10,7 @@ import type { AgentProvider, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useFormPreferences, type FormPreferences } from "./use-form-preferences"; // Explicit overrides from URL params or "New Agent" button @@ -367,7 +367,8 @@ export function useAgentFormState( }, [isVisible]); // Session state for provider model listing - const { client, isConnected } = useHostRuntimeSession(formState.serverId ?? ""); + const client = useHostRuntimeClient(formState.serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(formState.serverId ?? ""); const availableProvidersQuery = useQuery({ queryKey: ["availableProviders", formState.serverId], diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 5c4ab7dce..77cd21fee 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -3,8 +3,8 @@ import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { getHostRuntimeStore, - isHostRuntimeDirectoryLoading, - useHostRuntimeSession, + useHostRuntimeConnectionStatus, + useHostRuntimeIsDirectoryLoading, } from "@/runtime/host-runtime"; import type { AggregatedAgent, @@ -88,14 +88,14 @@ export function useAllAgentsList(options?: { const liveAgents = useSessionStore((state) => serverId ? state.sessions[serverId]?.agents ?? null : null ); - const { snapshot } = useHostRuntimeSession(serverId ?? ""); + const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? ""); const refreshAll = useCallback(() => { - if (!serverId || snapshot?.connectionStatus !== "online") { + if (!serverId || connectionStatus !== "online") { return; } void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined); - }, [runtime, serverId, snapshot?.connectionStatus]); + }, [runtime, serverId, connectionStatus]); const agents = useMemo(() => { if (!serverId || !liveAgents) { @@ -111,7 +111,7 @@ export function useAllAgentsList(options?: { }); }, [daemons, includeArchived, liveAgents, serverId]); - const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); + const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? ""); const isInitialLoad = isDirectoryLoading && agents.length === 0; const isRevalidating = isDirectoryLoading && agents.length > 0; diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index d5d0c57ee..4e088e1db 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useId, useMemo } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import { orderCheckoutDiffFiles } from "./checkout-diff-order"; @@ -56,7 +56,8 @@ export function useCheckoutDiffQuery({ enabled = true, }: UseCheckoutDiffQueryOptions) { const queryClient = useQueryClient(); - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index e4283505d..dcdfa2957 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse } from "@server/shared/messages"; const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; @@ -21,7 +21,8 @@ export function useCheckoutPrStatusQuery({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index dea647ea3..f1046c89e 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutStatusResponse } from "@server/shared/messages"; import { checkoutStatusRevalidationKey, @@ -30,7 +30,8 @@ function fetchCheckoutStatus( } export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); @@ -87,7 +88,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery * only the visible agents. */ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { client } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); return useQuery({ queryKey: checkoutStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 8cad27f3b..2582e0552 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,7 +1,7 @@ import { router } from "expo-router"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore, @@ -13,7 +13,7 @@ export function useOpenProject( ): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; const toast = useToast(); - const { client } = useHostRuntimeSession(normalizedServerId); + const client = useHostRuntimeClient(normalizedServerId); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const setHasHydratedWorkspaces = useSessionStore( (state) => state.setHasHydratedWorkspaces diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts index f34d98e42..6a5b5bbb9 100644 --- a/packages/app/src/hooks/use-project-icon-query.ts +++ b/packages/app/src/hooks/use-project-icon-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { ProjectIcon } from "@server/shared/messages"; export function projectIconQueryKey(serverId: string, cwd: string) { @@ -12,7 +12,8 @@ interface UseProjectIconQueryOptions { } export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ queryKey: projectIconQueryKey(serverId, cwd), diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index cddc2ae3c..c1455c5c8 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,12 +1,53 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Platform, Text, View } from "react-native"; +import ReanimatedAnimated from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Bot } from "lucide-react-native"; import invariant from "tiny-invariant"; -import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { FileDropZone } from "@/components/file-drop-zone"; +import type { ImageAttachment } from "@/components/message-input"; +import { ToastViewport, useToastHost } from "@/components/toast-host"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; +import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; +import { useAgentInitialization } from "@/hooks/use-agent-initialization"; +import { + useAgentScreenStateMachine, + type AgentScreenMissingState, +} from "@/hooks/use-agent-screen-state-machine"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { + useHostRuntimeClient, + useHostRuntimeConnectionStatus, + useHostRuntimeIsConnected, + useHostRuntimeLastError, + useHosts, + type HostRuntimeConnectionStatus, +} from "@/runtime/host-runtime"; +import { + getInitDeferred, + getInitKey, +} from "@/utils/agent-initialization"; +import { + derivePendingPermissionKey, + normalizeAgentSnapshot, +} from "@/utils/agent-snapshots"; +import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import { + deriveRouteBottomAnchorIntent, + deriveRouteBottomAnchorRequest, +} from "@/screens/agent/agent-ready-screen-bottom-anchor"; function formatProviderLabel(provider: Agent["provider"]): string { if (provider === "claude") { @@ -63,14 +104,13 @@ function useAgentPanelDescriptor( } function AgentPanel() { - const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( - { openFileInWorkspace(filePath); }} @@ -83,3 +123,897 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = { component: AgentPanel, useDescriptor: useAgentPanelDescriptor, }; + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +function logWebStickyBottom( + _event: string, + _details: Record +): void {} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function isNotFoundErrorMessage(message: string): boolean { + return /agent not found|not found/i.test(message); +} + +function AgentPanelContent({ + serverId, + agentId, + isPaneFocused, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId: string; + isPaneFocused: boolean; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const resolvedAgentId = agentId.trim() || undefined; + const resolvedServerId = serverId.trim() || undefined; + const daemons = useHosts(); + const runtimeServerId = resolvedServerId ?? ""; + const runtimeClient = useHostRuntimeClient(runtimeServerId); + const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); + const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); + const runtimeLastError = useHostRuntimeLastError(runtimeServerId); + + const connectionServerId = resolvedServerId ?? null; + const daemon = connectionServerId + ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null + : null; + const serverLabel = + daemon?.label ?? connectionServerId ?? "Selected host"; + const isUnknownDaemon = Boolean(connectionServerId && !daemon); + const connectionStatus: HostRuntimeConnectionStatus = + isUnknownDaemon && runtimeConnectionStatus === "connecting" + ? "offline" + : runtimeConnectionStatus; + const lastConnectionError = runtimeLastError; + + if (!resolvedServerId || !runtimeClient) { + return ( + + ); + } + + return ( + + ); +} + +function AgentPanelBody({ + serverId, + agentId, + isPaneFocused, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + isPaneFocused: boolean; + client: NonNullable>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const { theme } = useUnistyles(); + const panelToast = useToastHost(); + const { isArchivingAgent } = useArchiveAgent(); + const streamViewRef = useRef(null); + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + const clearOnAgentBlurRef = useRef<() => void>(() => {}); + const wasPaneFocusedRef = useRef(isPaneFocused); + const reconnectToastArmedRef = useRef(false); + const initAttemptTokenRef = useRef(0); + const routeBottomAnchorRequestRef = useRef<{ + routeKey: string; + reason: "initial-entry" | "resume"; + } | null>(null); + + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + + const handleAddImagesCallback = useCallback( + (addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, + [] + ); + + const agent = useSessionStore((state) => + agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined + ); + const streamItemsRaw = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentStreamTail?.get(agentId) + : undefined + ); + const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); + const markPendingCreateLifecycle = useCreateFlowStore( + (state) => state.markLifecycle + ); + const clearPendingCreate = useCreateFlowStore((state) => state.clear); + const isInitializingFromMap = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false + : false + ); + const historySyncGeneration = useSessionStore( + (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 + ); + const hasAppliedAuthoritativeHistory = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === + true + : false + ); + const agentHistorySyncGeneration = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1 + : -1 + ); + const allPendingPermissions = useSessionStore( + (state) => state.sessions[serverId]?.pendingPermissions + ); + const setAgents = useSessionStore((state) => state.setAgents); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setPendingPermissions = useSessionStore( + (state) => state.setPendingPermissions + ); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const { ensureAgentIsInitialized } = useAgentInitialization({ + serverId, + client: hasSession ? client : null, + }); + const [missingAgentState, setMissingAgentState] = useState({ + kind: "idle", + }); + + const pendingCreate = useMemo(() => { + if (!agentId) { + return null; + } + const values = Object.values(pendingByDraftId); + for (const entry of values) { + if ( + entry.lifecycle === "active" && + entry.serverId === serverId && + entry.agentId === agentId + ) { + return entry; + } + } + return null; + }, [agentId, pendingByDraftId, serverId]); + const isPendingCreateForPanel = Boolean(pendingCreate); + const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; + + const pendingPermissions = useMemo(() => { + if (!allPendingPermissions || !agentId) { + return new Map(); + } + const filtered = new Map(); + for (const [key, permission] of allPendingPermissions) { + if (permission.agentId === agentId) { + filtered.set(key, permission); + } + } + return filtered; + }, [agentId, allPendingPermissions]); + + const attentionController = useAgentAttentionClear({ + agentId, + client, + isConnected, + requiresAttention: agent?.requiresAttention, + attentionReason: agent?.attentionReason, + isScreenFocused: isPaneFocused, + }); + useEffect(() => { + clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; + }, [attentionController.clearOnAgentBlur]); + + const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ + mode: "translate", + }); + + const handleHistorySyncFailure = useCallback( + ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { + if (agentId) { + console.warn("[AgentPanel] history sync failed", { + origin, + agentId, + error, + }); + } + const message = toErrorMessage(error); + setMissingAgentState((previous) => { + if (previous.kind === "error" && previous.message === message) { + return previous; + } + return { kind: "error", message }; + }); + }, + [agentId] + ); + + const ensureInitializedWithSyncErrorHandling = useCallback( + (origin: "focus" | "entry") => { + if (!agentId) { + return; + } + ensureAgentIsInitialized(agentId).catch((error) => { + handleHistorySyncFailure({ origin, error }); + }); + }, + [agentId, ensureAgentIsInitialized, handleHistorySyncFailure] + ); + + useEffect(() => { + if (connectionStatus === "online") { + reconnectToastArmedRef.current = false; + return; + } + if (connectionStatus === "idle") { + return; + } + if (!reconnectToastArmedRef.current) { + reconnectToastArmedRef.current = true; + panelToast.api.show("Reconnecting...", { + durationMs: 2200, + testID: "agent-reconnecting-toast", + }); + } + }, [connectionStatus, panelToast.api]); + + useEffect(() => { + if (!isPaneFocused || !agentId || !isConnected || !hasSession) { + return; + } + ensureInitializedWithSyncErrorHandling("focus"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + isPaneFocused, + ]); + + const isArchivingCurrentAgent = Boolean( + agentId && + isArchivingAgent({ serverId, agentId }) + ); + + useEffect(() => { + if (wasPaneFocusedRef.current && !isPaneFocused) { + clearOnAgentBlurRef.current(); + } + wasPaneFocusedRef.current = isPaneFocused; + }, [isPaneFocused]); + + useEffect(() => { + return () => { + if (wasPaneFocusedRef.current) { + clearOnAgentBlurRef.current(); + } + }; + }, []); + + const isInitializing = agentId ? isInitializingFromMap !== false : false; + const isHistorySyncing = useMemo(() => { + if (!agentId || !isInitializing) { + return false; + } + const initKey = getInitKey(serverId, agentId); + return Boolean(getInitDeferred(initKey)); + }, [agentId, isInitializing, serverId]); + const needsAuthoritativeSync = useMemo(() => { + if (!agentId) { + return false; + } + return agentHistorySyncGeneration < historySyncGeneration; + }, [agentHistorySyncGeneration, agentId, historySyncGeneration]); + + const optimisticStreamItems = useMemo(() => { + if (!isPendingCreateForPanel || !pendingCreate) { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: pendingCreate.clientMessageId, + text: pendingCreate.text, + timestamp: new Date(pendingCreate.timestamp), + ...(pendingCreate.images && pendingCreate.images.length > 0 + ? { images: pendingCreate.images } + : {}), + }, + ]; + }, [isPendingCreateForPanel, pendingCreate]); + + const mergedStreamItems = useMemo(() => { + if (optimisticStreamItems.length === 0) { + return streamItems; + } + const optimistic = optimisticStreamItems[0]; + if (!optimistic) { + return streamItems; + } + const alreadyHasOptimistic = streamItems.some( + (item) => item.kind === "user_message" && item.id === optimistic.id + ); + return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; + }, [optimisticStreamItems, streamItems]); + + const shouldUseOptimisticStream = + isPendingCreateForPanel && optimisticStreamItems.length > 0; + const authoritativeStatus = agent?.status; + const isAuthoritativeBootstrapping = + authoritativeStatus === "initializing" || authoritativeStatus === "idle"; + const showPendingCreateSubmitLoading = + isPendingCreateForPanel && + (!authoritativeStatus || isAuthoritativeBootstrapping); + const canFinalizePendingCreate = + Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; + + const placeholderAgent: Agent | null = useMemo(() => { + if (!shouldUseOptimisticStream || !agentId) { + return null; + } + const now = new Date(); + return { + serverId, + id: agentId, + provider: "claude", + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "claude", + sessionId: null, + model: null, + modeId: null, + }, + title: "Agent", + cwd: ".", + model: null, + labels: {}, + }; + }, [agentId, serverId, shouldUseOptimisticStream]); + + const viewState = useAgentScreenStateMachine({ + routeKey: `${serverId}:${agentId ?? ""}`, + input: { + agent: agent ?? null, + placeholderAgent, + missingAgentState, + isConnected, + isArchivingCurrentAgent, + isHistorySyncing, + needsAuthoritativeSync, + shouldUseOptimisticStream, + hasHydratedHistoryBefore, + }, + }); + + const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; + const routeEntryKey = agentId ? `${serverId}:${agentId}` : null; + routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ + cachedIntent: routeBottomAnchorRequestRef.current, + routeKey: routeEntryKey, + hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, + }); + const routeBottomAnchorRequest = useMemo( + () => + deriveRouteBottomAnchorRequest({ + intent: routeBottomAnchorRequestRef.current, + effectiveAgentId: effectiveAgent?.id ?? null, + }), + [effectiveAgent?.id] + ); + + useEffect(() => { + if (!isPendingCreateForPanel || !pendingCreate) { + return; + } + const hasUserMessage = streamItems.some( + (item) => + item.kind === "user_message" && + item.id === pendingCreate.clientMessageId + ); + if (hasUserMessage && canFinalizePendingCreate) { + if ( + agentId && + pendingCreate.images && + pendingCreate.images.length > 0 + ) { + setAgentStreamTail(serverId, (previous) => { + const current = previous.get(agentId); + if (!current) { + return previous; + } + + const merged = mergePendingCreateImages({ + streamItems: current, + clientMessageId: pendingCreate.clientMessageId, + images: pendingCreate.images, + }); + if (merged === current) { + return previous; + } + + const next = new Map(previous); + next.set(agentId, merged); + return next; + }); + } + markPendingCreateLifecycle({ + draftId: pendingCreate.draftId, + lifecycle: "sent", + }); + clearPendingCreate({ draftId: pendingCreate.draftId }); + } + }, [ + agentId, + canFinalizePendingCreate, + clearPendingCreate, + isPendingCreateForPanel, + markPendingCreateLifecycle, + pendingCreate, + serverId, + setAgentStreamTail, + streamItems, + ]); + + useEffect(() => { + if (!agentId) { + return; + } + if (!isConnected || !hasSession) { + return; + } + const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; + if (!shouldSyncOnEntry) { + return; + } + + ensureInitializedWithSyncErrorHandling("entry"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + needsAuthoritativeSync, + ]); + + useEffect(() => { + initAttemptTokenRef.current += 1; + setMissingAgentState({ kind: "idle" }); + }, [agentId, serverId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (agent || shouldUseOptimisticStream) { + if (missingAgentState.kind !== "idle") { + setMissingAgentState({ kind: "idle" }); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + return; + } + + setMissingAgentState({ kind: "resolving" }); + const attemptToken = ++initAttemptTokenRef.current; + + ensureAgentIsInitialized(agentId) + .then(async () => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const currentAgent = useSessionStore + .getState() + .sessions[serverId] + ?.agents.get(agentId); + if (!currentAgent) { + const result = await client.fetchAgent(agentId); + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + if (!result) { + setMissingAgentState({ + kind: "not_found", + message: `Agent not found: ${agentId}`, + }); + return; + } + const normalized = normalizeAgentSnapshot(result.agent, serverId); + const hydrated = { + ...normalized, + projectPlacement: result.project, + }; + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + setPendingPermissions(serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next.entries()) { + if (pending.agentId === hydrated.id) { + next.delete(key); + } + } + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); + } + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + setMissingAgentState({ kind: "idle" }); + }) + .catch((error) => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setMissingAgentState({ kind: "not_found", message }); + return; + } + setMissingAgentState({ kind: "error", message }); + }); + }, [ + agent, + agentId, + client, + ensureAgentIsInitialized, + hasSession, + isConnected, + missingAgentState.kind, + serverId, + setAgents, + setPendingPermissions, + shouldUseOptimisticStream, + ]); + + const isHistoryRefreshCatchingUp = + viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "toast"; + const shouldEmitSyncErrorToast = + viewState.tag === "ready" && + viewState.sync.status === "sync_error" && + viewState.sync.shouldEmitSyncErrorToast; + + useDelayedHistoryRefreshToast({ + isCatchingUp: isHistoryRefreshCatchingUp, + indicatorColor: theme.colors.primary, + showToast: panelToast.api.show, + }); + + useEffect(() => { + if (!shouldEmitSyncErrorToast) { + return; + } + panelToast.api.error("Failed to refresh agent. Retrying in background."); + }, [panelToast.api, shouldEmitSyncErrorToast]); + + if (viewState.tag === "not_found") { + return ( + + + Agent not found + + + ); + } + + if (viewState.tag === "error") { + return ( + + + Failed to load agent + {viewState.message} + + + ); + } + + if (viewState.tag === "boot" || !effectiveAgent) { + return ( + + + + + + ); + } + + return ( + + + + + + + + + + {agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? ( + { + logWebStickyBottom("screen_composer_height_change", { + agentId, + height, + }); + streamViewRef.current?.prepareForViewportChange(); + }} + onMessageSent={() => { + logWebStickyBottom("screen_message_sent_scroll_to_bottom", { + agentId, + }); + streamViewRef.current?.scrollToBottom("message-sent"); + }} + /> + ) : null} + + {viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "overlay" ? ( + + + + ) : null} + + + + + + {isArchivingCurrentAgent ? ( + + + Archiving agent... + + Please wait while we archive this agent. + + + ) : null} + + ); +} + +function AgentSessionUnavailableState({ + serverLabel, + connectionStatus, + lastError, + isUnknownDaemon = false, +}: { + serverLabel: string; + connectionStatus: HostRuntimeConnectionStatus; + lastError: string | null; + isUnknownDaemon?: boolean; +}) { + if (isUnknownDaemon) { + return ( + + + + Cannot open this agent because {serverLabel} is not configured on + this device. + + + Add the host in Settings or open an agent on a configured server to + continue. + + + + ); + } + + const isConnecting = connectionStatus === "connecting"; + const isPreparingSession = connectionStatus === "online"; + + return ( + + + {isConnecting || isPreparingSession ? ( + <> + + + {isPreparingSession + ? `Preparing ${serverLabel} session...` + : `Connecting to ${serverLabel}...`} + + + {isPreparingSession + ? "We will show this agent in a moment." + : "We will show this agent once the host is online."} + + + ) : ( + <> + + Reconnecting to {serverLabel}... + + + We will show this agent again as soon as the host is reachable. + + {lastError ? ( + {lastError} + ) : null} + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + root: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + flex: 1, + overflow: "hidden", + }, + content: { + flex: 1, + }, + historySyncOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: theme.colors.surface0, + alignItems: "center", + justifyContent: "center", + zIndex: 40, + }, + archivingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: "rgba(8, 10, 14, 0.86)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[8], + gap: theme.spacing[3], + zIndex: 50, + }, + archivingTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + archivingSubtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + loadingText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + gap: theme.spacing[3], + }, + errorContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + fontSize: theme.fontSize.lg, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + statusText: { + marginTop: theme.spacing[2], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + offlineTitle: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + offlineDescription: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + offlineDetails: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 6b5241f5c..2358d8dba 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -6,6 +6,7 @@ export interface PaneContextValue { serverId: string; workspaceId: string; tabId: string; + isPaneFocused: boolean; target: WorkspaceTabTarget; openTab(target: WorkspaceTabTarget): void; closeCurrentTab(): void; diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 082407c45..42f1c5fe1 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1734,17 +1734,13 @@ export function useHostRuntimeSnapshot( ); } -export function useHostRuntimeSession(serverId: string): { - snapshot: HostRuntimeSnapshot | null; - client: DaemonClient | null; - isConnected: boolean; -} { - const snapshot = useHostRuntimeSnapshot(serverId); - return { - snapshot, - client: snapshot?.client ?? null, - isConnected: isHostRuntimeConnected(snapshot), - }; +export function useHostRuntimeClient(serverId: string): DaemonClient | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.client ?? null, + () => store.getSnapshot(serverId)?.client ?? null + ); } export function useHostRuntimeIsConnected(serverId: string): boolean { @@ -1756,6 +1752,46 @@ export function useHostRuntimeIsConnected(serverId: string): boolean { ); } +export function useHostRuntimeConnectionStatus( + serverId: string +): HostRuntimeConnectionStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting" + ); +} + +export function useHostRuntimeLastError(serverId: string): string | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.lastError ?? null, + () => store.getSnapshot(serverId)?.lastError ?? null + ); +} + +export function useHostRuntimeAgentDirectoryStatus( + serverId: string +): HostRuntimeAgentDirectoryStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle", + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle" + ); +} + +export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)), + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)) + ); +} + export function useHosts(): HostProfile[] { const store = getHostRuntimeStore(); return useSyncExternalStore( diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx deleted file mode 100644 index 324dd6c72..000000000 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ /dev/null @@ -1,1160 +0,0 @@ -import { useEffect, useMemo, useRef, useCallback, useState } from "react"; -import { - View, - Text, - ActivityIndicator, - Platform, - BackHandler, -} from "react-native"; -import { useFocusEffect, useIsFocused } from "@react-navigation/native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useQueryClient } from "@tanstack/react-query"; -import ReanimatedAnimated from "react-native-reanimated"; -import { GestureDetector } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { ToastViewport, useToastHost } from "@/components/toast-host"; -import { ExplorerSidebar } from "@/components/explorer-sidebar"; -import { FileDropZone } from "@/components/file-drop-zone"; -import type { ImageAttachment } from "@/components/message-input"; -import { - ExplorerSidebarAnimationProvider, -} from "@/contexts/explorer-sidebar-animation-context"; -import { usePanelStore } from "@/stores/panel-store"; -import { useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { - useHostRuntimeSession, - type HostRuntimeConnectionStatus, -} from "@/runtime/host-runtime"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import type { Agent } from "@/contexts/session-context"; -import type { StreamItem } from "@/types/stream"; -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, - useCheckoutStatusQuery, -} from "@/hooks/use-checkout-status-query"; -import { useAgentInitialization } from "@/hooks/use-agent-initialization"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { - useAgentScreenStateMachine, - type AgentScreenMissingState, -} from "@/hooks/use-agent-screen-state-machine"; -import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; -import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; -import { - derivePendingPermissionKey, - normalizeAgentSnapshot, -} from "@/utils/agent-snapshots"; -import { mergePendingCreateImages } from "@/utils/pending-create-images"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; -import type { DaemonClient } from "@server/client/daemon-client"; -import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; -import type { ExplorerCheckoutContext } from "@/stores/panel-store"; -import { - deriveRouteBottomAnchorIntent, - deriveRouteBottomAnchorRequest, -} from "./agent-ready-screen-bottom-anchor"; - -const EMPTY_STREAM_ITEMS: StreamItem[] = []; -function logWebStickyBottom( - _event: string, - _details: Record -): void { - // Intentionally disabled: this path is too noisy during voice debugging. -} - -export function AgentReadyScreen({ - serverId, - agentId, - showExplorerSidebar = true, - wrapWithExplorerSidebarProvider = true, - onOpenWorkspaceFile, -}: { - serverId: string; - agentId: string; - showExplorerSidebar?: boolean; - wrapWithExplorerSidebarProvider?: boolean; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; -}) { - const resolvedAgentId = agentId?.trim() || undefined; - const resolvedServerId = serverId?.trim() || undefined; - const daemons = useHosts(); - const runtimeServerId = resolvedServerId ?? ""; - const { - snapshot: runtimeSnapshot, - client: runtimeClient, - isConnected: runtimeIsConnected, - } = useHostRuntimeSession(runtimeServerId); - - const connectionServerId = resolvedServerId ?? null; - const daemon = connectionServerId - ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null - : null; - const serverLabel = - daemon?.label ?? connectionServerId ?? "Selected host"; - const isUnknownDaemon = Boolean(connectionServerId && !daemon); - const connectionStatus: HostRuntimeConnectionStatus = - runtimeSnapshot?.connectionStatus ?? - (isUnknownDaemon ? "offline" : "connecting"); - const lastConnectionError = runtimeSnapshot?.lastError ?? null; - const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); - - if (!resolvedServerId || !runtimeClient) { - return ( - - ); - } - - const content = ( - - ); - - if (!wrapWithExplorerSidebarProvider) { - return content; - } - - return {content}; -} - -type AgentScreenContentProps = { - serverId: string; - agentId?: string; - client: DaemonClient; - isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; - showExplorerSidebar: boolean; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; -}; - -function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function isNotFoundErrorMessage(message: string): boolean { - return /agent not found|not found/i.test(message); -} - -function AgentScreenContent({ - serverId, - agentId, - client, - isConnected, - connectionStatus, - showExplorerSidebar, - onOpenWorkspaceFile, -}: AgentScreenContentProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const queryClient = useQueryClient(); - const panelToast = useToastHost(); - const resolvedAgentId = agentId; - const { isArchivingAgent } = useArchiveAgent(); - - const streamViewRef = useRef(null); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files); - }, []); - - const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, []); - - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const openFileExplorer = usePanelStore((state) => state.openFileExplorer); - const closeToAgent = usePanelStore((state) => state.closeToAgent); - const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); - const activateExplorerTabForCheckout = usePanelStore( - (state) => state.activateExplorerTabForCheckout - ); - - // Derive isExplorerOpen from the unified panel state - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - // Select only the specific agent - const agent = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agents?.get(resolvedAgentId) - : undefined - ); - // Checkout status for header subtitle + git fallback when cached project placement is absent - const checkoutStatusQuery = useCheckoutStatusQuery({ - serverId, - cwd: agent?.cwd ?? "", - }); - const checkout = checkoutStatusQuery.status; - const resolveCachedCheckoutIsGit = useCallback( - (params: { - cwd?: string | null; - projectPlacementIsGit?: boolean; - checkoutStatusIsGit?: boolean; - }): boolean | null => { - if (typeof params.projectPlacementIsGit === "boolean") { - return params.projectPlacementIsGit; - } - - const cwd = params.cwd?.trim(); - if (!cwd) { - return null; - } - const cachedCheckout = queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd) - ); - if (typeof cachedCheckout?.isGit === "boolean") { - return cachedCheckout.isGit; - } - if (typeof params.checkoutStatusIsGit === "boolean") { - return params.checkoutStatusIsGit; - } - return null; - }, - [queryClient, serverId] - ); - const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => { - if (!resolvedAgentId) { - return null; - } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents?.get(resolvedAgentId); - const cwd = currentAgent?.cwd?.trim(); - const isGit = resolveCachedCheckoutIsGit({ - cwd, - projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit, - checkoutStatusIsGit: checkout?.isGit, - }); - if (!cwd || typeof isGit !== "boolean") { - return null; - } - return { serverId, cwd, isGit }; - }, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]); - const openExplorerForActiveCheckout = useCallback(() => { - const checkoutContext = resolveCurrentExplorerCheckout(); - if (checkoutContext) { - activateExplorerTabForCheckout(checkoutContext); - } - openFileExplorer(); - }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); - // Swipe-left gesture to open explorer sidebar on mobile - const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && mobileView === "agent", - onOpen: openExplorerForActiveCheckout, - }); - - // Handle hardware back button - close explorer sidebar first, then navigate back - useEffect(() => { - if (Platform.OS === "web") return; - - const handler = BackHandler.addEventListener("hardwareBackPress", () => { - if (isExplorerOpen) { - closeToAgent(); - return true; // Prevent default back navigation - } - return false; // Let default back navigation happen - }); - - return () => handler.remove(); - }, [isExplorerOpen, closeToAgent]); - - const activeExplorerCheckout = useMemo(() => { - const cwd = agent?.cwd?.trim(); - const isGit = resolveCachedCheckoutIsGit({ - cwd, - projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit, - checkoutStatusIsGit: checkout?.isGit, - }); - if (!cwd || typeof isGit !== "boolean") { - return null; - } - return { serverId, cwd, isGit }; - }, [ - agent?.cwd, - agent?.projectPlacement?.checkout?.isGit, - resolveCachedCheckoutIsGit, - resolvedAgentId, - checkout?.isGit, - serverId, - ]); - - useEffect(() => { - setActiveExplorerCheckout(activeExplorerCheckout); - }, [activeExplorerCheckout, setActiveExplorerCheckout]); - - useEffect(() => { - if (!activeExplorerCheckout) { - return; - } - activateExplorerTabForCheckout(activeExplorerCheckout); - }, [activateExplorerTabForCheckout, activeExplorerCheckout]); - - useEffect(() => { - return () => { - setActiveExplorerCheckout(null); - }; - }, [setActiveExplorerCheckout]); - - // Select only the specific stream tail - use stable empty array to avoid infinite loop - const streamItemsRaw = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId) - : undefined - ); - const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; - - const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); - const markPendingCreateLifecycle = useCreateFlowStore( - (state) => state.markLifecycle - ); - const clearPendingCreate = useCreateFlowStore((state) => state.clear); - const pendingCreate = useMemo(() => { - if (!resolvedAgentId) { - return null; - } - const values = Object.values(pendingByDraftId); - for (const entry of values) { - if ( - entry.lifecycle === "active" && - entry.serverId === serverId && - entry.agentId === resolvedAgentId - ) { - return entry; - } - } - return null; - }, [pendingByDraftId, resolvedAgentId, serverId]); - const isPendingCreateForRoute = Boolean(pendingCreate); - - // Select only the specific initializing state - const isInitializingFromMap = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false - : false - ); - const historySyncGeneration = useSessionStore( - (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 - ); - const hasAppliedAuthoritativeHistory = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get( - resolvedAgentId - ) === true - : false - ); - const agentHistorySyncGeneration = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 - : -1 - ); - const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; - - // Select raw pending permissions - filter with useMemo to avoid new Map on every render - const allPendingPermissions = useSessionStore( - (state) => state.sessions[serverId]?.pendingPermissions - ); - const setAgents = useSessionStore((state) => state.setAgents); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setPendingPermissions = useSessionStore( - (state) => state.setPendingPermissions - ); - const pendingPermissions = useMemo(() => { - if (!allPendingPermissions || !resolvedAgentId) return new Map(); - const filtered = new Map(); - for (const [key, perm] of allPendingPermissions) { - if (perm.agentId === resolvedAgentId) { - filtered.set(key, perm); - } - } - return filtered; - }, [allPendingPermissions, resolvedAgentId]); - - const hasSession = useSessionStore( - (state) => Boolean(state.sessions[serverId]) - ); - const isScreenFocused = useIsFocused(); - const { ensureAgentIsInitialized } = useAgentInitialization({ - serverId, - client: hasSession ? client : null, - }); - const [missingAgentState, setMissingAgentState] = useState({ - kind: "idle", - }); - const reconnectToastArmedRef = useRef(false); - const initAttemptTokenRef = useRef(0); - const routeBottomAnchorRequestRef = useRef<{ - routeKey: string; - reason: "initial-entry" | "resume"; - } | null>(null); - const setFocusedAgentId = useCallback( - (agentId: string | null) => { - useSessionStore.getState().setFocusedAgentId(serverId, agentId); - }, - [serverId] - ); - - const attentionController = useAgentAttentionClear({ - agentId: resolvedAgentId, - client, - isConnected, - requiresAttention: agent?.requiresAttention, - attentionReason: agent?.attentionReason, - isScreenFocused, - }); - - const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const handleHistorySyncFailure = useCallback( - ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { - if (resolvedAgentId) { - console.warn("[AgentScreen] history sync failed", { - origin, - agentId: resolvedAgentId, - error, - }); - } - const message = toErrorMessage(error); - setMissingAgentState((prev) => { - if (prev.kind === "error" && prev.message === message) { - return prev; - } - return { kind: "error", message }; - }); - }, - [resolvedAgentId] - ); - - const ensureInitializedWithSyncErrorHandling = useCallback( - (origin: "focus" | "entry") => { - if (!resolvedAgentId) { - return; - } - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - handleHistorySyncFailure({ origin, error }); - }); - }, - [ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId] - ); - - useEffect(() => { - if (connectionStatus === "online") { - reconnectToastArmedRef.current = false; - return; - } - if (connectionStatus === "idle") { - return; - } - if (!reconnectToastArmedRef.current) { - reconnectToastArmedRef.current = true; - panelToast.api.show("Reconnecting...", { - durationMs: 2200, - testID: "agent-reconnecting-toast", - }); - } - }, [connectionStatus, panelToast.api]); - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [ - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - resolvedAgentId, - ]) - ); - - const isGitCheckout = activeExplorerCheckout?.isGit ?? false; - const isArchivingCurrentAgent = Boolean( - resolvedAgentId && - isArchivingAgent({ serverId, agentId: resolvedAgentId }) - ); - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId) { - setFocusedAgentId(null); - return; - } - - setFocusedAgentId(resolvedAgentId); - return () => { - attentionController.clearOnAgentBlur(); - setFocusedAgentId(null); - }; - }, [attentionController, resolvedAgentId, setFocusedAgentId]) - ); - - const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; - const isHistorySyncing = useMemo(() => { - if (!resolvedAgentId || !isInitializing) { - return false; - } - const initKey = getInitKey(serverId, resolvedAgentId); - return Boolean(getInitDeferred(initKey)); - }, [resolvedAgentId, isInitializing, serverId]); - const needsAuthoritativeSync = useMemo(() => { - if (!resolvedAgentId) { - return false; - } - return agentHistorySyncGeneration < historySyncGeneration; - }, [agentHistorySyncGeneration, historySyncGeneration, resolvedAgentId]); - - const optimisticStreamItems = useMemo(() => { - if (!isPendingCreateForRoute || !pendingCreate) { - return EMPTY_STREAM_ITEMS; - } - return [ - { - kind: "user_message", - id: pendingCreate.clientMessageId, - text: pendingCreate.text, - timestamp: new Date(pendingCreate.timestamp), - ...(pendingCreate.images && pendingCreate.images.length > 0 - ? { images: pendingCreate.images } - : {}), - }, - ]; - }, [isPendingCreateForRoute, pendingCreate]); - - const mergedStreamItems = useMemo(() => { - if (optimisticStreamItems.length === 0) { - return streamItems; - } - const optimistic = optimisticStreamItems[0]; - if (!optimistic) { - return streamItems; - } - const alreadyHasOptimistic = streamItems.some( - (item) => item.kind === "user_message" && item.id === optimistic.id - ); - return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; - }, [optimisticStreamItems, streamItems]); - - const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0; - const authoritativeStatus = agent?.status; - const isAuthoritativeBootstrapping = - authoritativeStatus === "initializing" || authoritativeStatus === "idle"; - const showPendingCreateSubmitLoading = - isPendingCreateForRoute && - (!authoritativeStatus || isAuthoritativeBootstrapping); - const canFinalizePendingCreate = - Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; - - const placeholderAgent: Agent | null = useMemo(() => { - if (!shouldUseOptimisticStream || !resolvedAgentId) { - return null; - } - const now = new Date(); - return { - serverId, - id: resolvedAgentId, - provider: "claude", - status: "running", - createdAt: now, - updatedAt: now, - lastUserMessageAt: now, - lastActivityAt: now, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: "claude", - sessionId: null, - model: null, - modeId: null, - }, - title: "Agent", - cwd: ".", - model: null, - labels: {}, - }; - }, [resolvedAgentId, serverId, shouldUseOptimisticStream]); - - const viewState = useAgentScreenStateMachine({ - routeKey: `${serverId}:${resolvedAgentId ?? ""}`, - input: { - agent: agent ?? null, - placeholderAgent, - missingAgentState, - isConnected, - isArchivingCurrentAgent, - isHistorySyncing, - needsAuthoritativeSync, - shouldUseOptimisticStream, - hasHydratedHistoryBefore, - }, - }); - - const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; - const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null; - routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ - cachedIntent: routeBottomAnchorRequestRef.current, - routeKey: routeEntryKey, - hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, - }); - const routeBottomAnchorRequest = useMemo( - () => - deriveRouteBottomAnchorRequest({ - intent: routeBottomAnchorRequestRef.current, - effectiveAgentId: effectiveAgent?.id ?? null, - }), - [effectiveAgent?.id] - ); - useEffect(() => { - if (!isPendingCreateForRoute || !pendingCreate) { - return; - } - const hasUserMessage = streamItems.some( - (item) => - item.kind === "user_message" && - item.id === pendingCreate.clientMessageId - ); - if (hasUserMessage && canFinalizePendingCreate) { - if ( - resolvedAgentId && - pendingCreate.images && - pendingCreate.images.length > 0 - ) { - setAgentStreamTail(serverId, (prev) => { - const current = prev.get(resolvedAgentId); - if (!current) { - return prev; - } - - const merged = mergePendingCreateImages({ - streamItems: current, - clientMessageId: pendingCreate.clientMessageId, - images: pendingCreate.images, - }); - if (merged === current) { - return prev; - } - - const next = new Map(prev); - next.set(resolvedAgentId, merged); - return next; - }); - } - markPendingCreateLifecycle({ draftId: pendingCreate.draftId, lifecycle: "sent" }); - clearPendingCreate({ draftId: pendingCreate.draftId }); - } - }, [ - canFinalizePendingCreate, - clearPendingCreate, - isPendingCreateForRoute, - markPendingCreateLifecycle, - pendingCreate, - resolvedAgentId, - serverId, - setAgentStreamTail, - streamItems, - ]); - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - - if (!isConnected || !hasSession) { - return; - } - // On native clients, daemon stream forwarding is focused-agent only, so switching - // agents can leave timeline gaps unless we explicitly pull timeline catch-up. - const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; - if (!shouldSyncOnEntry) { - return; - } - - ensureInitializedWithSyncErrorHandling("entry"); - }, [ - resolvedAgentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - needsAuthoritativeSync, - ]); - - useEffect(() => { - // Clear stale resolution state when route target changes. - initAttemptTokenRef.current += 1; - setMissingAgentState({ kind: "idle" }); - }, [serverId, resolvedAgentId]); - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - if (agent || shouldUseOptimisticStream) { - if (missingAgentState.kind !== "idle") { - setMissingAgentState({ kind: "idle" }); - } - return; - } - if (!isConnected || !hasSession) { - return; - } - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { - return; - } - - setMissingAgentState({ kind: "resolving" }); - const attemptToken = ++initAttemptTokenRef.current; - - ensureAgentIsInitialized(resolvedAgentId) - .then(async () => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents.get(resolvedAgentId); - if (!currentAgent && client) { - const result = await client.fetchAgent(resolvedAgentId); - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - if (!result) { - setMissingAgentState({ - kind: "not_found", - message: `Agent not found: ${resolvedAgentId}`, - }); - return; - } - const normalized = normalizeAgentSnapshot(result.agent, serverId); - const hydrated = { - ...normalized, - projectPlacement: result.project, - }; - setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(hydrated.id, hydrated); - return next; - }); - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - for (const [key, pending] of next.entries()) { - if (pending.agentId === hydrated.id) { - next.delete(key); - } - } - for (const request of hydrated.pendingPermissions) { - const key = derivePendingPermissionKey(hydrated.id, request); - next.set(key, { key, agentId: hydrated.id, request }); - } - return next; - }); - } - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - setMissingAgentState({ kind: "idle" }); - }) - .catch((error) => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const message = toErrorMessage(error); - if (isNotFoundErrorMessage(message)) { - setMissingAgentState({ kind: "not_found", message }); - return; - } - setMissingAgentState({ kind: "error", message }); - }); - }, [ - agent, - client, - ensureAgentIsInitialized, - hasSession, - isConnected, - missingAgentState.kind, - resolvedAgentId, - serverId, - setAgents, - setPendingPermissions, - shouldUseOptimisticStream, - ]); - - useEffect(() => { - if (Platform.OS !== "web") { - return; - } - const title = agent?.title || "Agent"; - document.title = title; - }, [agent?.title]); - - const isHistoryRefreshCatchingUp = - viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "toast"; - const shouldEmitSyncErrorToast = - viewState.tag === "ready" && - viewState.sync.status === "sync_error" && - viewState.sync.shouldEmitSyncErrorToast; - - useDelayedHistoryRefreshToast({ - isCatchingUp: isHistoryRefreshCatchingUp, - indicatorColor: theme.colors.primary, - showToast: panelToast.api.show, - }); - - useEffect(() => { - if (!shouldEmitSyncErrorToast) { - return; - } - panelToast.api.error("Failed to refresh agent. Retrying in background."); - }, [panelToast.api, shouldEmitSyncErrorToast]); - - if (viewState.tag === "not_found") { - return ( - - - Agent not found - - - ); - } - - if (viewState.tag === "error") { - return ( - - - Failed to load agent - {viewState.message} - - - ); - } - - if (viewState.tag === "boot" || !effectiveAgent) { - return ( - - - - - - ); - } - - const mainContent = ( - - - - {/* Content Area with Keyboard Animation */} - - - - - - - {/* Agent Input Area */} - {resolvedAgentId && !isArchivingCurrentAgent && ( - { - logWebStickyBottom("screen_composer_height_change", { - agentId: resolvedAgentId, - height, - }); - streamViewRef.current?.prepareForViewportChange(); - }} - onMessageSent={() => { - logWebStickyBottom("screen_message_sent_scroll_to_bottom", { - agentId: resolvedAgentId, - }); - streamViewRef.current?.scrollToBottom("message-sent"); - }} - /> - )} - - {viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "overlay" ? ( - - - - ) : null} - - - - - - - {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} - {showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? ( - - ) : null} - - {isArchivingCurrentAgent ? ( - - - Archiving agent... - - Please wait while we archive this agent. - - - ) : null} - - ); - - return ( - <> - {isMobile ? ( - - {mainContent} - - ) : ( - mainContent - )} - - {/* Mobile Explorer Sidebar Overlay */} - {showExplorerSidebar && isMobile && resolvedAgentId ? ( - - ) : null} - - ); -} - -function AgentSessionUnavailableState({ - serverLabel, - connectionStatus, - lastError, - isUnknownDaemon = false, -}: { - serverLabel: string; - connectionStatus: HostRuntimeConnectionStatus; - lastError: string | null; - isUnknownDaemon?: boolean; -}) { - if (isUnknownDaemon) { - return ( - - - - Cannot open this agent because {serverLabel} is not configured on - this device. - - - Add the host in Settings or open an agent on a configured server to - continue. - - - - ); - } - - const isConnecting = connectionStatus === "connecting"; - const isPreparingSession = connectionStatus === "online"; - - return ( - - - {isConnecting || isPreparingSession ? ( - <> - - - {isPreparingSession - ? `Preparing ${serverLabel} session...` - : `Connecting to ${serverLabel}...`} - - - {isPreparingSession - ? "We will show this agent in a moment." - : "We will show this agent once the host is online."} - - - ) : ( - <> - - Reconnecting to {serverLabel}... - - - We will show this agent again as soon as the host is reachable. - - {lastError ? ( - {lastError} - ) : null} - - )} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - outerContainer: { - flex: 1, - flexDirection: "row", - backgroundColor: theme.colors.surface0, - }, - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - contentContainer: { - flex: 1, - overflow: "hidden", - }, - content: { - flex: 1, - }, - historySyncOverlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: theme.colors.surface0, - alignItems: "center", - justifyContent: "center", - zIndex: 40, - }, - archivingOverlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: "rgba(8, 10, 14, 0.86)", - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[8], - gap: theme.spacing[3], - zIndex: 50, - }, - archivingTitle: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - archivingSubtitle: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - loadingText: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - }, - centerState: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[6], - gap: theme.spacing[3], - }, - errorContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - }, - errorText: { - fontSize: theme.fontSize.lg, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - statusText: { - marginTop: theme.spacing[2], - textAlign: "center", - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - errorDetails: { - marginTop: theme.spacing[1], - textAlign: "center", - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - offlineTitle: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - offlineDescription: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - offlineDetails: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, -})); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 80e9dd327..5d7aeaa7f 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -33,7 +33,7 @@ import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-sugg import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' import { useSessionStore } from '@/stores/session-store' import { generateDraftId } from '@/stores/draft-keys' -import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime' +import { getHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store' import { MAX_CONTENT_WIDTH } from '@/constants/layout' @@ -317,9 +317,8 @@ function DraftAgentScreenContent({ return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]) }, [allAgents, sessionAgents]) - const { client: runtimeClient, isConnected: isHostOnline } = useHostRuntimeSession( - selectedServerId ?? '' - ) + const runtimeClient = useHostRuntimeClient(selectedServerId ?? '') + const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? '') const sessionClient = runtimeClient const trimmedWorkingDir = workingDir.trim() const shouldInspectRepo = trimmedWorkingDir.length > 0 diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 0759cdab2..7404aab26 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -24,7 +24,9 @@ import { useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore, isHostRuntimeConnected, - useHostRuntimeSession, + useHostRuntimeClient, + useHostRuntimeIsConnected, + useHostRuntimeSnapshot, } from "@/runtime/host-runtime"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; @@ -993,9 +995,9 @@ function HostDetailModal({ const connections = host?.connections ?? []; // Restart logic (moved from DaemonCard) - const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession( - host?.serverId ?? "" - ); + const runtimeSnapshot = useHostRuntimeSnapshot(host?.serverId ?? ""); + const runtimeClient = useHostRuntimeClient(host?.serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(host?.serverId ?? ""); const runtime = getHostRuntimeStore(); const daemonClient = runtimeClient; const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null); @@ -1390,7 +1392,7 @@ function DaemonCard({ onOpenSettings, }: DaemonCardProps) { const { theme } = useUnistyles(); - const { snapshot } = useHostRuntimeSession(daemon.serverId); + const snapshot = useHostRuntimeSnapshot(daemon.serverId); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; const lastError = snapshot?.lastError ?? null; diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 80b61dfa8..063916191 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -4,6 +4,7 @@ import { canOpenAgentTabFromRoute, deriveWorkspaceAgentVisibility, shouldPruneWorkspaceAgentTab, + workspaceAgentVisibilityEqual, } from "@/screens/workspace/workspace-agent-visibility"; function makeAgent(input: { @@ -53,7 +54,7 @@ function makeAgent(input: { } describe("workspace agent visibility", () => { - it("keeps archived agents hidden from visible list but present in workspace lookup", () => { + it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { const workspaceId = "/repo/worktree"; const visible = makeAgent({ id: "visible-agent", @@ -82,87 +83,46 @@ describe("workspace agent visibility", () => { workspaceId, }); - expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["visible-agent"]); - expect(result.lookupById.has("visible-agent")).toBe(true); - expect(result.lookupById.has("archived-agent")).toBe(true); - expect(result.lookupById.has("other-workspace-agent")).toBe(false); + expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); + expect(result.knownAgentIds.has("visible-agent")).toBe(true); + expect(result.knownAgentIds.has("archived-agent")).toBe(true); + expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); it("allows explicit route open for archived agent once agents are hydrated", () => { - const archivedAgent = makeAgent({ - id: "archived-agent", - cwd: "/repo/worktree", - archivedAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const lookup = new Map([[archivedAgent.id, archivedAgent]]); + const knownAgentIds = new Set(["archived-agent"]); expect( canOpenAgentTabFromRoute({ agentId: "archived-agent", agentsHydrated: true, - workspaceAgentLookup: lookup, + knownAgentIds, }) ).toBe(true); }); - it("does not prune archived agent tabs when the workspace lookup still contains the agent", () => { - const archivedAgent = makeAgent({ - id: "archived-agent", - cwd: "/repo/worktree", - archivedAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const lookup = new Map([[archivedAgent.id, archivedAgent]]); + it("does not prune archived agent tabs when knownAgentIds contains the agent", () => { + const knownAgentIds = new Set(["archived-agent"]); expect( shouldPruneWorkspaceAgentTab({ agentId: "archived-agent", agentsHydrated: true, - workspaceAgentLookup: lookup, + knownAgentIds, }) ).toBe(false); }); - it("prunes agent tabs once agents are hydrated and the agent is missing from the workspace lookup", () => { + it("prunes agent tabs once agents are hydrated and the agent is missing from knownAgentIds", () => { expect( shouldPruneWorkspaceAgentTab({ agentId: "missing-agent", agentsHydrated: true, - workspaceAgentLookup: new Map(), + knownAgentIds: new Set(), }) ).toBe(true); }); - it("sorts same-createdAt agents by lastActivityAt descending", () => { - const workspaceId = "/repo/worktree"; - const createdAt = new Date("2026-03-04T00:00:00.000Z"); - const newerActivity = makeAgent({ - id: "newer-activity", - cwd: workspaceId, - createdAt, - lastActivityAt: new Date("2026-03-04T00:05:00.000Z"), - }); - const olderActivity = makeAgent({ - id: "older-activity", - cwd: workspaceId, - createdAt, - lastActivityAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const sessionAgents = new Map([ - [olderActivity.id, olderActivity], - [newerActivity.id, newerActivity], - ]); - - const result = deriveWorkspaceAgentVisibility({ - sessionAgents, - workspaceId, - }); - - expect(result.visibleAgents.map((agent) => agent.id)).toEqual([ - "newer-activity", - "older-activity", - ]); - }); - it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => { const sessionAgents = new Map([ [ @@ -179,7 +139,33 @@ describe("workspace agent visibility", () => { workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid", }); - expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["slash-agent"]); - expect(result.lookupById.has("slash-agent")).toBe(true); + expect(result.activeAgentIds).toEqual(new Set(["slash-agent"])); + expect(result.knownAgentIds.has("slash-agent")).toBe(true); + }); + + describe("workspaceAgentVisibilityEqual", () => { + it("returns true for identical sets", () => { + const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + const b = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); + }); + + it("returns false when activeAgentIds differ", () => { + const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; + const b = { activeAgentIds: new Set(["b"]), knownAgentIds: new Set(["a"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns false when knownAgentIds differ", () => { + const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; + const b = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a", "b"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns true for empty sets", () => { + const a = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + const b = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); + }); }); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index db79bd9f9..5d90bf7f9 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -1,68 +1,66 @@ import type { Agent } from "@/stores/session-store"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; -function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { - const sorted = [...agents]; - sorted.sort((left, right) => { - const createdAtDelta = right.createdAt.getTime() - left.createdAt.getTime(); - if (createdAtDelta !== 0) { - return createdAtDelta; - } - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); - }); - return sorted; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (!value) { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - function normalizeWorkspaceId(value: string | null | undefined): string { return normalizeWorkspaceIdentity(value) ?? ""; } +export interface WorkspaceAgentVisibility { + activeAgentIds: Set; + knownAgentIds: Set; +} + export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; workspaceId: string; -}): { - visibleAgents: Agent[]; - lookupById: Map; -} { +}): WorkspaceAgentVisibility { const { sessionAgents, workspaceId } = input; const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId); if (!sessionAgents || !workspaceId) { return { - visibleAgents: [], - lookupById: new Map(), + activeAgentIds: new Set(), + knownAgentIds: new Set(), }; } - const lookupById = new Map(); - const visible: Agent[] = []; + const activeAgentIds = new Set(); + const knownAgentIds = new Set(); for (const agent of sessionAgents.values()) { if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) { continue; } - lookupById.set(agent.id, agent); + knownAgentIds.add(agent.id); if (!agent.archivedAt) { - visible.push(agent); + activeAgentIds.add(agent.id); } } - return { - visibleAgents: sortAgentsByCreatedAtDescending(visible), - lookupById, - }; + return { activeAgentIds, knownAgentIds }; +} + +export function workspaceAgentVisibilityEqual( + a: WorkspaceAgentVisibility, + b: WorkspaceAgentVisibility, +): boolean { + return setsEqual(a.activeAgentIds, b.activeAgentIds) && setsEqual(a.knownAgentIds, b.knownAgentIds); +} + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) { + return false; + } + for (const item of a) { + if (!b.has(item)) { + return false; + } + } + return true; } export function canOpenAgentTabFromRoute(input: { agentId: string; agentsHydrated: boolean; - workspaceAgentLookup: Map; + knownAgentIds: Set; }): boolean { if (!input.agentId.trim()) { return false; @@ -70,13 +68,13 @@ export function canOpenAgentTabFromRoute(input: { if (!input.agentsHydrated) { return true; } - return input.workspaceAgentLookup.has(input.agentId); + return input.knownAgentIds.has(input.agentId); } export function shouldPruneWorkspaceAgentTab(input: { agentId: string; agentsHydrated: boolean; - workspaceAgentLookup: Map; + knownAgentIds: Set; }): boolean { if (!input.agentId.trim()) { return false; @@ -84,5 +82,5 @@ export function shouldPruneWorkspaceAgentTab(input: { if (!input.agentsHydrated) { return false; } - return !input.workspaceAgentLookup.has(input.agentId); + return !input.knownAgentIds.has(input.agentId); } diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 721417b99..3669d68e3 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -7,7 +7,7 @@ import { AgentStreamView } from '@/components/agent-stream-view' import type { ImageAttachment } from '@/components/message-input' import { useAgentFormState } from '@/hooks/use-agent-form-state' import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow' -import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import type { Agent } from '@/stores/session-store' import { encodeImages } from '@/utils/encode-images' import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types' @@ -40,7 +40,8 @@ export function WorkspaceDraftAgentTab({ onCreated, onOpenWorkspaceFile, }: WorkspaceDraftAgentTabProps) { - const { client, isConnected } = useHostRuntimeSession(serverId) + const client = useHostRuntimeClient(serverId) + const isConnected = useHostRuntimeIsConnected(serverId) const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null) const { diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 0e0c99f60..2260ae10d 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -15,6 +15,7 @@ export interface BuildWorkspacePaneContentModelInput { tab: WorkspaceTabDescriptor; normalizedServerId: string; normalizedWorkspaceId: string; + isPaneFocused: boolean; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onCloseCurrentTab: () => void; onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; @@ -25,6 +26,7 @@ export function buildWorkspacePaneContentModel({ tab, normalizedServerId, normalizedWorkspaceId, + isPaneFocused, onOpenTab, onCloseCurrentTab, onRetargetCurrentTab, @@ -40,6 +42,7 @@ export function buildWorkspacePaneContentModel({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: tab.tabId, + isPaneFocused, target: tab.target, openTab: onOpenTab, closeCurrentTab: onCloseCurrentTab, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 274536eec..fbecfc0f7 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, @@ -70,7 +71,7 @@ import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, @@ -96,6 +97,7 @@ import { import { deriveWorkspaceAgentVisibility, shouldPruneWorkspaceAgentTab, + workspaceAgentVisibilityEqual, } from "@/screens/workspace/workspace-agent-visibility"; import { deriveWorkspacePaneState, @@ -279,6 +281,25 @@ function ResolvedMobileActiveTabTrigger({ ); } +function WorkspaceDocumentTitleEffect({ + label, + titleState, +}: { + label: string; + titleState: "ready" | "loading"; +}) { + useEffect(() => { + if (Platform.OS !== "web" || typeof document === "undefined") { + return; + } + const resolvedLabel = label.trim(); + document.title = + titleState === "loading" ? "Loading..." : resolvedLabel || "Workspace"; + }, [label, titleState]); + + return null; +} + function MobileWorkspaceTabOption({ tab, tabIndex, @@ -519,6 +540,12 @@ export function WorkspaceScreen({ workspaceId, openIntent, }: WorkspaceScreenProps) { + const isFocused = useIsFocused(); + + if (!isFocused) { + return ; + } + return ( state.sessions[normalizedServerId]?.agents - ); - const workspaceAgentVisibility = useMemo( - () => + const workspaceAgentVisibility = useStoreWithEqualityFn( + useSessionStore, + (state) => deriveWorkspaceAgentVisibility({ - sessionAgents, + sessionAgents: state.sessions[normalizedServerId]?.agents, workspaceId: normalizedWorkspaceId, }), - [normalizedWorkspaceId, sessionAgents] + workspaceAgentVisibilityEqual, ); - const workspaceAgents = workspaceAgentVisibility.visibleAgents; const terminalsQueryKey = useMemo( () => ["terminals", normalizedServerId, normalizedWorkspaceId] as const, @@ -798,8 +822,6 @@ function WorkspaceScreenContent({ return () => handler.remove(); }, [closeToAgent, isExplorerOpen]); - const agentsById = workspaceAgentVisibility.lookupById; - const persistenceKey = useMemo( () => buildWorkspaceTabPersistenceKey({ @@ -828,7 +850,6 @@ function WorkspaceScreenContent({ const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const consumedOpenIntentsRef = useRef(new Set()); - const reopenedArchivedOpenIntentsRef = useRef(new Set()); const pendingCloseTabIdsRef = useRef(new Set()); const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState(null); const currentOpenIntentKey = useMemo( @@ -849,6 +870,24 @@ function WorkspaceScreenContent({ }), [uiTabs, workspaceLayout] ); + const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); + const focusedPaneAgentId = useMemo(() => { + const target = focusedPaneTabState.activeTab?.descriptor.target; + if (target?.kind !== "agent") { + return null; + } + return target.agentId; + }, [focusedPaneTabState.activeTab]); + + useEffect(() => { + setFocusedAgentId(normalizedServerId, focusedPaneAgentId); + }, [focusedPaneAgentId, normalizedServerId, setFocusedAgentId]); + + useEffect(() => { + return () => { + setFocusedAgentId(normalizedServerId, null); + }; + }, [normalizedServerId, setFocusedAgentId]); const ensureWorkspaceTab = useCallback( function ensureWorkspaceTab(target: WorkspaceTabTarget): string | null { @@ -914,43 +953,6 @@ function WorkspaceScreenContent({ } }, [currentOpenIntentKey, resolvedOpenIntentKey]); - useEffect(() => { - if (!client || !isConnected || openIntent?.kind !== "agent" || !currentOpenIntentKey) { - return; - } - - const agentId = openIntent.agentId.trim(); - if (!agentId) { - return; - } - if (reopenedArchivedOpenIntentsRef.current.has(currentOpenIntentKey)) { - return; - } - - const agent = agentsById.get(agentId) ?? null; - if (!agent?.archivedAt) { - return; - } - - reopenedArchivedOpenIntentsRef.current.add(currentOpenIntentKey); - void client.refreshAgent(agentId).catch((error) => { - console.warn("[WorkspaceScreen] Failed to reopen archived agent", { - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - agentId, - error, - }); - }); - }, [ - agentsById, - client, - currentOpenIntentKey, - isConnected, - normalizedServerId, - normalizedWorkspaceId, - openIntent, - ]); - useEffect(() => { if (!openIntent || !persistenceKey) { return; @@ -1032,12 +1034,12 @@ function WorkspaceScreenContent({ return pending?.serverId === normalizedServerId && pending.lifecycle === "active"; }); - for (const agent of workspaceAgents) { + for (const agentId of workspaceAgentVisibility.activeAgentIds) { const representedByTarget = uiTabs.some( - (tab) => tab.target.kind === "agent" && tab.target.agentId === agent.id + (tab) => tab.target.kind === "agent" && tab.target.agentId === agentId ); const representedByDeterministicTabId = uiTabs.some( - (tab) => tab.tabId === `agent_${agent.id}` + (tab) => tab.tabId === `agent_${agentId}` ); if ( hasActivePendingDraftCreateInWorkspace && @@ -1046,7 +1048,7 @@ function WorkspaceScreenContent({ ) { continue; } - ensureWorkspaceTab({ kind: "agent", agentId: agent.id }); + ensureWorkspaceTab({ kind: "agent", agentId }); } for (const terminal of terminals) { ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id }); @@ -1061,7 +1063,7 @@ function WorkspaceScreenContent({ shouldPruneWorkspaceAgentTab({ agentId: tab.target.agentId, agentsHydrated: hasHydratedAgents, - workspaceAgentLookup: agentsById, + knownAgentIds: workspaceAgentVisibility.knownAgentIds, }) ) { closeWorkspaceTab(persistenceKey, tab.tabId); @@ -1083,7 +1085,7 @@ function WorkspaceScreenContent({ terminals, terminalsQuery.isSuccess, uiTabs, - workspaceAgents, + workspaceAgentVisibility, ]); const activeTabId = resolvedPaneTabState.activeTabId; @@ -1137,7 +1139,7 @@ function WorkspaceScreenContent({ emptyWorkspaceSeedRef.current = null; return; } - if (workspaceAgents.length > 0 || terminals.length > 0) { + if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) { emptyWorkspaceSeedRef.current = null; return; } @@ -1159,7 +1161,7 @@ function WorkspaceScreenContent({ persistenceKey, terminals.length, tabs.length, - workspaceAgents.length, + workspaceAgentVisibility.activeAgentIds.size, ]); const handleOpenFileFromExplorer = useCallback( @@ -1442,7 +1444,7 @@ function WorkspaceScreenContent({ const handleCopyResumeCommand = useCallback( async (agentId: string) => { if (!agentId) return; - const agent = sessionAgents?.get(agentId) ?? null; + const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; const providerSessionId = agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; if (!agent || !providerSessionId) { @@ -1467,7 +1469,7 @@ function WorkspaceScreenContent({ toast.error("Copy failed"); } }, - [sessionAgents, toast] + [normalizedServerId, toast] ); const handleCopyWorkspacePath = useCallback(async () => { @@ -1773,7 +1775,7 @@ function WorkspaceScreenContent({ ] as const, enabled: Boolean(normalizedServerId && normalizedWorkspaceId), priority: 100, - isActive: () => isScreenFocused, + isActive: () => true, handle: handleWorkspaceTabAction, }); @@ -1794,21 +1796,29 @@ function WorkspaceScreenContent({ ] as const, enabled: Boolean(normalizedServerId && normalizedWorkspaceId), priority: 100, - isActive: () => isScreenFocused, + isActive: () => true, handle: handleWorkspacePaneAction, }); const activeTabDescriptor = activeTab?.descriptor ?? null; + useEffect(() => { + if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) { + return; + } + document.title = "Workspace"; + }, [activeTabDescriptor]); const buildPaneContentModel = useCallback( (input: { tab: WorkspaceTabDescriptor; paneId?: string | null; + isPaneFocused: boolean; focusPaneBeforeOpen?: boolean; }) => buildWorkspacePaneContentModel({ tab: input.tab, normalizedServerId, normalizedWorkspaceId, + isPaneFocused: input.isPaneFocused, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -1855,6 +1865,7 @@ function WorkspaceScreenContent({ ? buildPaneContentModel({ tab: activeTabDescriptor, paneId: focusedPaneTabState.pane?.id ?? null, + isPaneFocused: true, focusPaneBeforeOpen: false, }) : null, @@ -1885,6 +1896,20 @@ function WorkspaceScreenContent({ return ( + {Platform.OS === "web" && activeTabDescriptor ? ( + + {(presentation) => ( + + )} + + ) : null} - buildPaneContentModel({ - tab, - paneId, - focusPaneBeforeOpen: true, - }) - } + buildPaneContentModel={({ paneId, tab, isPaneFocused }) => + buildPaneContentModel({ + tab, + paneId, + isPaneFocused, + focusPaneBeforeOpen: true, + }) + } onFocusPane={(paneId) => { focusWorkspacePane(persistenceKey, paneId); }} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2fd230f24..09fd1f30d 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -328,7 +328,10 @@ class DaemonRpcError extends Error { readonly code?: string constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { - super(params.error) + const parts = [params.error] + if (params.requestType) parts.push(`requestType=${params.requestType}`) + if (params.code) parts.push(`code=${params.code}`) + super(parts.join(' ')) this.name = 'DaemonRpcError' this.requestId = params.requestId this.requestType = params.requestType diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index d35e0d80a..7be627ea5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -147,6 +147,45 @@ describe("convertClaudeHistoryEntry", () => { ]); }); + test("maps queue-operation task notifications to synthetic tool calls", () => { + const entry = { + type: "queue-operation", + operation: "enqueue", + uuid: "task-note-queue-1", + content: [ + "", + "bg-queue-1", + "completed", + "Background task completed", + "/tmp/bg-queue-1.txt", + "", + ].join("\n"), + }; + + expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + { + type: "tool_call", + callId: "task_notification_task-note-queue-1", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "wrench", + text: entry.content, + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-queue-1", + status: "completed", + outputFile: "/tmp/bg-queue-1.txt", + }, + }, + ]); + }); + test("passes assistant content blocks through to the mapper", () => { const entry = { type: "assistant", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 4fd2d06d8..fcd948ee0 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -29,6 +29,7 @@ import { mapClaudeRunningToolCall, } from "./claude/tool-call-mapper.js"; import { + coerceTaskNotificationHistoryRecordToSystemMessage, isTaskNotificationUserContent, mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, @@ -1495,6 +1496,9 @@ class ClaudeAgentSession implements AgentSession { private readonly timelineAssembler = new TimelineAssembler(); private persistedHistory: AgentTimelineItem[] = []; private historyPending = false; + private historyOffsetSessionId: string | null = null; + private historyReadOffsetBytes = 0; + private historyLineFragment = ""; private turnState: TurnState = "idle"; private preReplayMetadataSeen = false; private pendingAutonomousWakeReservations = 0; @@ -3074,6 +3078,9 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; this.persistedHistory = []; this.historyPending = false; + this.historyOffsetSessionId = null; + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; this.cachedRuntimeInfo = null; this.queryRestartNeeded = false; return true; @@ -3998,46 +4005,163 @@ class ClaudeAgentSession implements AgentSession { } } - private waitForLiveHistoryPoll(): Promise { - return new Promise((resolve) => setTimeout(resolve, 250)); + private async waitForLiveHistoryPoll(): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)); + if (this.claudeSessionId) { + this.loadPersistedHistory(this.claudeSessionId, { dispatchLive: true }); + } } - private loadPersistedHistory(sessionId: string) { + private loadPersistedHistory( + sessionId: string, + options?: { dispatchLive?: boolean } + ) { try { const historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { return; } - const content = fs.readFileSync(historyPath, "utf8"); - const timeline: AgentTimelineItem[] = []; - for (const line of content.split(/\n+/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed); - if (entry.isSidechain) { - continue; - } - if (entry.type === "user" && typeof entry.uuid === "string") { - this.rememberUserMessageId(entry.uuid); - } - const items = this.convertHistoryEntry(entry); - if (items.length > 0) { - timeline.push(...items); - } - } catch (error) { - // ignore malformed history line - } + if (this.historyOffsetSessionId !== sessionId) { + this.historyOffsetSessionId = sessionId; + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; } - if (timeline.length > 0) { - this.persistedHistory = timeline; - this.historyPending = true; + const content = fs.readFileSync(historyPath); + if (content.byteLength < this.historyReadOffsetBytes) { + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; } + if (content.byteLength === this.historyReadOffsetBytes) { + return; + } + + const unreadChunk = content + .subarray(this.historyReadOffsetBytes) + .toString("utf8"); + this.historyReadOffsetBytes = content.byteLength; + this.ingestPersistedHistoryChunk(unreadChunk, { + dispatchLive: options?.dispatchLive ?? false, + }); } catch (error) { // ignore history load failures } } + private ingestPersistedHistoryChunk( + chunk: string, + options: { dispatchLive: boolean } + ): void { + if (!chunk) { + return; + } + + const combined = `${this.historyLineFragment}${chunk}`; + this.historyLineFragment = ""; + const lines = combined.split(/\r?\n/); + const trailing = lines.pop() ?? ""; + const timeline: AgentTimelineItem[] = []; + + for (const line of lines) { + this.ingestPersistedHistoryLine(line, { + dispatchLive: options.dispatchLive, + timeline, + }); + } + + if (trailing.trim().length > 0) { + const handled = this.ingestPersistedHistoryLine(trailing, { + dispatchLive: options.dispatchLive, + timeline, + }); + if (!handled) { + this.historyLineFragment = trailing; + } + } + + if (!options.dispatchLive && timeline.length > 0) { + this.persistedHistory = [...this.persistedHistory, ...timeline]; + this.historyPending = true; + } + } + + private ingestPersistedHistoryLine( + line: string, + options: { + dispatchLive: boolean; + timeline: AgentTimelineItem[]; + } + ): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return true; + } + + let entry: Record; + try { + entry = JSON.parse(trimmed) as Record; + } catch { + return false; + } + + if (entry.isSidechain) { + return true; + } + if (entry.type === "user" && typeof entry.uuid === "string") { + this.rememberUserMessageId(entry.uuid); + } + + if (options.dispatchLive) { + this.dispatchPersistedHistoryEntry(entry); + return true; + } + + const items = this.convertHistoryEntry(entry); + if (items.length > 0) { + options.timeline.push(...items); + } + return true; + } + + private dispatchPersistedHistoryEntry(entry: Record): void { + const liveMessage = this.normalizePersistedHistoryEntryToLiveMessage(entry); + if (liveMessage) { + this.routeSdkMessageFromPump(liveMessage); + return; + } + + const items = this.convertHistoryEntry(entry); + for (const item of items) { + this.pushEvent({ + type: "timeline", + item, + provider: "claude", + }); + } + } + + private normalizePersistedHistoryEntryToLiveMessage( + entry: Record + ): SDKMessage | null { + const taskNotificationMessage = + coerceTaskNotificationHistoryRecordToSystemMessage(entry); + if (taskNotificationMessage) { + return taskNotificationMessage as unknown as SDKMessage; + } + + const type = readTrimmedString(entry.type); + switch (type) { + case "assistant": + case "result": + case "stream_event": + case "system": + case "tool_progress": + case "user": + return entry as unknown as SDKMessage; + default: + return null; + } + } + private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; @@ -4624,13 +4748,9 @@ export function convertClaudeHistoryEntry( }]; } - if (entry.type === "system") { - const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall( - entry - ); - if (taskNotificationItem) { - return [taskNotificationItem]; - } + const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry); + if (taskNotificationItem) { + return [taskNotificationItem]; } if (entry.isCompactSummary) { diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index 1a1e25912..a983f7605 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -22,6 +22,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed"; +const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER"; function buildSdkQueryMock() { const events = [ @@ -70,6 +72,19 @@ function buildSdkQueryMock() { }; } +function buildIdleSdkQueryMock() { + return { + next: vi.fn(async () => ({ done: true, value: undefined })), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + }; +} + function collectTimelineText(events: AgentStreamEvent[]): string { const chunks: string[] = []; for (const event of events) { @@ -86,6 +101,28 @@ function collectTimelineText(events: AgentStreamEvent[]): string { return chunks.join("\n"); } +async function readNextEvent( + iterator: AsyncIterator, + timeoutMs: number +): Promise { + const outcome = await Promise.race([ + iterator.next().then((result) => ({ kind: "result" as const, result })), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); + }), + ]); + + if (outcome.kind === "timeout") { + throw new Error("Timed out waiting for live event"); + } + + if (outcome.result.done) { + throw new Error("Live event stream ended before appended transcript arrived"); + } + + return outcome.result.value; +} + describe("ClaudeAgentSession history replay regression", () => { let tempRoot: string; let cwd: string; @@ -215,6 +252,117 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + test("emits appended transcript lines through streamLiveEvents after history was primed", async () => { + sdkMocks.query.mockImplementation(() => { + const mock = buildIdleSdkQueryMock(); + sdkMocks.lastQuery = mock; + return mock; + }); + + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const historyPath = path.join( + configDir, + "projects", + sanitized, + "history-session.jsonl" + ); + + const session = await client.resumeSession(handle, { cwd }); + + try { + for await (const _event of session.streamHistory()) { + // Prime existing persisted history the same way agent-manager does. + } + + const liveEvents = session.streamLiveEvents(); + const iterator = liveEvents[Symbol.asyncIterator](); + + appendFileSync( + historyPath, + `\n${JSON.stringify({ + type: "queue-operation", + operation: "enqueue", + uuid: "appended-task-note-1", + content: [ + "", + "appended-bg-1", + "completed", + `${APPENDED_TASK_NOTIFICATION_MARKER}`, + "/tmp/appended-bg-1.txt", + "", + ].join("\n"), + })}\n${JSON.stringify({ + type: "assistant", + sessionId: "history-session", + cwd, + message: { + role: "assistant", + content: APPENDED_ASSISTANT_MARKER, + }, + })}`, + "utf8" + ); + + const appendedEvents: AgentStreamEvent[] = []; + for (let attempt = 0; attempt < 4; attempt += 1) { + appendedEvents.push(await readNextEvent(iterator, 1_500)); + const sawTaskNotification = appendedEvents.some( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ); + const sawAssistant = appendedEvents.some( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes(APPENDED_ASSISTANT_MARKER) + ); + if (sawTaskNotification && sawAssistant) { + break; + } + } + const timelineText = collectTimelineText(appendedEvents); + const taskNotificationEvent = appendedEvents.find( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ); + const turnStartedEvent = appendedEvents.find( + (event): event is Extract => + event.type === "turn_started" + ); + + expect(taskNotificationEvent).toBeTruthy(); + expect(turnStartedEvent).toBeTruthy(); + expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER); + expect(taskNotificationEvent?.item.metadata).toMatchObject({ + taskId: "appended-bg-1", + status: "completed", + outputFile: "/tmp/appended-bg-1.txt", + }); + expect(taskNotificationEvent?.item.detail).toMatchObject({ + type: "plain_text", + label: APPENDED_TASK_NOTIFICATION_MARKER, + }); + } finally { + await session.close(); + } + }); + test("listCommands includes rewind command", async () => { const logger = createTestLogger(); const client = new ClaudeAgentClient({ logger }); diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index 17bcc9f9e..1017da89e 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -32,9 +32,10 @@ const TaskNotificationUserContentSchema = z.union([ z.array(TaskNotificationUserContentBlockSchema), ]); -const TaskNotificationSystemRecordSchema = z +const TaskNotificationHistoryRecordSchema = z .object({ - subtype: z.literal("task_notification"), + type: z.string().optional(), + subtype: z.string().optional(), uuid: z.string().optional(), message_id: z.string().optional(), task_id: z.string().optional(), @@ -57,13 +58,24 @@ export type MapTaskNotificationUserContentToToolCallInput = z.infer< >; type TaskNotificationUserContent = z.infer; -type TaskNotificationSystemRecord = z.infer; +type TaskNotificationHistoryRecord = z.infer; type TaskNotificationLifecycle = | { status: "completed"; error: null } | { status: "failed"; error: unknown } | { status: "canceled"; error: null }; +export type TaskNotificationSystemMessageLike = { + type: "system"; + subtype: "task_notification"; + uuid?: string; + task_id?: string; + status?: "completed" | "failed" | "stopped"; + summary?: string; + output_file?: string; + content?: string; +}; + type ReadTaskNotificationTagInput = { text: string; tagName: string; @@ -157,12 +169,18 @@ function parseTaskNotificationFromUserContent( function parseTaskNotificationFromSystemRecord( record: unknown ): TaskNotificationEnvelope | null { - const parsedRecord = TaskNotificationSystemRecordSchema.safeParse(record); + const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record); if (!parsedRecord.success) { return null; } - const systemRecord: TaskNotificationSystemRecord = parsedRecord.data; + const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data; + const isSystemTaskNotification = + systemRecord.type === "system" && systemRecord.subtype === "task_notification"; + const isQueueOperation = systemRecord.type === "queue-operation"; + if (!isSystemTaskNotification && !isQueueOperation) { + return null; + } const rawText = toNonEmptyString(systemRecord.content); return TaskNotificationEnvelopeSchema.parse({ @@ -330,3 +348,31 @@ export function mapTaskNotificationSystemRecordToToolCall( } return toTaskNotificationToolCall(parsed); } + +export function coerceTaskNotificationHistoryRecordToSystemMessage( + record: unknown +): TaskNotificationSystemMessageLike | null { + const parsed = parseTaskNotificationFromSystemRecord(record); + if (!parsed) { + return null; + } + + const normalizedStatus = parsed.status?.toLowerCase() ?? null; + const status = + normalizedStatus === "failed" || normalizedStatus === "error" + ? "failed" + : normalizedStatus === "canceled" || normalizedStatus === "cancelled" + ? "stopped" + : "completed"; + + return { + type: "system", + subtype: "task_notification", + ...(parsed.messageId ? { uuid: parsed.messageId } : {}), + ...(parsed.taskId ? { task_id: parsed.taskId } : {}), + status, + ...(parsed.summary ? { summary: parsed.summary } : {}), + ...(parsed.outputFile ? { output_file: parsed.outputFile } : {}), + ...(parsed.rawText ? { content: parsed.rawText } : {}), + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ae5e1c52..2f51d3ed7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1723,7 +1723,7 @@ export class Session { payload: { requestId, requestType: msg.type, - error: 'Request failed', + error: `Request failed: ${err.message}`, code: 'handler_error', }, }) diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7cf9e83d2..e2448aa7a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1109,36 +1109,44 @@ export async function getCheckoutShortstat( const configured = await getConfiguredBaseRefForCwd(cwd, context); const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); - if (!localBaseRef) { - return null; - } - const currentBranch = await getCurrentBranch(cwd); - if (currentBranch === localBaseRef) { - return null; - } - const comparisonBaseRef = await resolveBestComparisonBaseRef( - cwd, - normalizeLocalBranchRefName(localBaseRef) - ); + let diffTarget: string; - let mergeBase: string; - try { - const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, { + if (currentBranch && localBaseRef && currentBranch !== localBaseRef) { + // Feature branch: diff against the merge-base with the base branch + const comparisonBaseRef = await resolveBestComparisonBaseRef( cwd, - env: READ_ONLY_GIT_ENV, - }); - mergeBase = stdout.trim(); - if (!mergeBase) { + normalizeLocalBranchRefName(localBaseRef) + ); + + try { + const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const mergeBase = stdout.trim(); + if (!mergeBase) { + return null; + } + diffTarget = mergeBase; + } catch { return null; } - } catch { + } else if (currentBranch) { + // On the base branch (or no base ref configured): diff against remote tracking branch + const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`); + if (!hasOrigin) { + return null; + } + diffTarget = `origin/${currentBranch}`; + } else { return null; } try { - const { stdout } = await execAsync(`git diff --shortstat ${mergeBase} HEAD`, { + // Omit HEAD so the diff includes uncommitted (staged + unstaged) changes + const { stdout } = await execAsync(`git diff --shortstat ${diffTarget}`, { cwd, env: READ_ONLY_GIT_ENV, });