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,
});