Merge branch 'main' into read-electron-migration-skills

# Conflicts:
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/left-sidebar.tsx
This commit is contained in:
Mohamed Boudra
2026-03-21 01:52:25 +07:00
36 changed files with 2169 additions and 1769 deletions
+62 -48
View File
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
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 = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
if (!isMobile) {
return content;
}
return (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>
{content}
</MobileGestureWrapper>
);
}
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 = (
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<View style={{ flex: 1, flexDirection: "row" }}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={{ flex: 1 }}>
{children}
</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
</View>
);
if (!isMobile) {
return content;
}
return (
<GestureDetector gesture={openGesture} touchAction="pan-y">
{content}
{children}
</GestureDetector>
);
}
@@ -435,6 +441,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
{children}
</VoiceProvider>
);
@@ -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() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
options={{ freezeOnBlur: true }}
/>
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
options={{ gestureEnabled: false, freezeOnBlur: true }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
@@ -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;
@@ -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))
+418 -252
View File
@@ -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<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['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<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
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<View>(null)
const hostTriggerRef = useRef<View | null>(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 (
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeToAgent={closeToAgent}
handleOpenProject={handleOpenProjectMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
}
// 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 (
<DesktopSidebar
{...sharedProps}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
// 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 (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
sidebarAnimatedStyle,
]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header */}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
hovered && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
</View>
</View>
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProjectMobile}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
</View>
</View>
</Animated.View>
</GestureDetector>
</View>
)
}
// Desktop: no edge swipe, just show/hide based on isOpen
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
parentGestureRef={closeGestureRef}
/>
)}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
</View>
</Animated.View>
</GestureDetector>
</View>
)
}
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
</View>
</View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
@@ -490,7 +657,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
@@ -502,7 +668,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
@@ -519,7 +685,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProjectDesktop}
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
@@ -544,7 +710,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsDesktop}
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
@@ -15,7 +15,7 @@ import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
@@ -34,7 +34,8 @@ export function ProjectPickerModal() {
return daemons[0]?.serverId ?? null;
}, [pathname, daemons]);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
);
@@ -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 (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
</View>
)
}
return (
@@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : shouldShowSyncedLoader ? (
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
) : bucket === 'needs_input' ? (
<CircleHelp size={14} color={theme.colors.palette.amber[500]} />
) : bucket === 'attention' ? (
<Check size={14} color={theme.colors.palette.green[500]} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
@@ -213,7 +225,7 @@ function ProjectLeadingVisual({
{showChevron && chevron !== null ? (
<ProjectInlineChevron chevron={chevron} />
) : shouldShowWorkspaceStatus && activeWorkspace ? (
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} workspaceKind={activeWorkspace.workspaceKind} loading={isArchiving} />
) : iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
@@ -686,6 +698,7 @@ function WorkspaceRowInner({
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
@@ -1903,7 +1916,7 @@ const styles = StyleSheet.create((theme) => ({
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,
@@ -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,
@@ -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]);
@@ -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<SidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
}),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef]
);
return (
<SidebarAnimationContext.Provider
value={{
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
}}
>
<SidebarAnimationContext.Provider value={value}>
{children}
</SidebarAnimationContext.Provider>
);
@@ -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'
@@ -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),
@@ -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],
@@ -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;
@@ -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);
@@ -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),
@@ -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),
+2 -2
View File
@@ -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<boolean> {
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
@@ -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),
+940 -6
View File
@@ -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 (
<AgentReadyScreen
<AgentPanelContent
serverId={serverId}
agentId={target.agentId}
showExplorerSidebar={false}
wrapWithExplorerSidebarProvider={false}
isPaneFocused={isPaneFocused}
onOpenWorkspaceFile={({ filePath }) => {
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<string, unknown>
): 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 (
<AgentSessionUnavailableState
serverLabel={serverLabel}
connectionStatus={connectionStatus}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
);
}
return (
<AgentPanelBody
serverId={resolvedServerId}
agentId={resolvedAgentId}
isPaneFocused={isPaneFocused}
client={runtimeClient}
isConnected={runtimeIsConnected}
connectionStatus={connectionStatus}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
function AgentPanelBody({
serverId,
agentId,
isPaneFocused,
client,
isConnected,
connectionStatus,
onOpenWorkspaceFile,
}: {
serverId: string;
agentId?: string;
isPaneFocused: boolean;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const { theme } = useUnistyles();
const panelToast = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(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<AgentScreenMissingState>({
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<string, PendingPermission>();
}
const filtered = new Map<string, PendingPermission>();
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<StreamItem[]>(() => {
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<StreamItem[]>(() => {
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 (
<View style={styles.container} testID="agent-not-found">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
if (viewState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{viewState.message}</Text>
</View>
</View>
);
}
if (viewState.tag === "boot" || !effectiveAgent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
</View>
);
}
return (
<View style={styles.root}>
<FileDropZone
onFilesDropped={handleFilesDropped}
disabled={isArchivingCurrentAgent}
>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
<AgentStreamView
ref={streamViewRef}
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? (
<AgentInputArea
agentId={agentId}
serverId={serverId}
autoFocus={isPaneFocused}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={(height) => {
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" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>
Please wait while we archive this agent.
</Text>
</View>
) : null}
</View>
);
}
function AgentSessionUnavailableState({
serverLabel,
connectionStatus,
lastError,
isUnknownDaemon = false,
}: {
serverLabel: string;
connectionStatus: HostRuntimeConnectionStatus;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
if (isUnknownDaemon) {
return (
<View style={styles.container}>
<View style={styles.centerState}>
<Text style={styles.errorText}>
Cannot open this agent because {serverLabel} is not configured on
this device.
</Text>
<Text style={styles.statusText}>
Add the host in Settings or open an agent on a configured server to
continue.
</Text>
</View>
</View>
);
}
const isConnecting = connectionStatus === "connecting";
const isPreparingSession = connectionStatus === "online";
return (
<View style={styles.container}>
<View style={styles.centerState}>
{isConnecting || isPreparingSession ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>
{isPreparingSession
? `Preparing ${serverLabel} session...`
: `Connecting to ${serverLabel}...`}
</Text>
<Text style={styles.statusText}>
{isPreparingSession
? "We will show this agent in a moment."
: "We will show this agent once the host is online."}
</Text>
</>
) : (
<>
<Text style={styles.offlineTitle}>
Reconnecting to {serverLabel}...
</Text>
<Text style={styles.offlineDescription}>
We will show this agent again as soon as the host is reachable.
</Text>
{lastError ? (
<Text style={styles.offlineDetails}>{lastError}</Text>
) : null}
</>
)}
</View>
</View>
);
}
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",
},
}));
+1
View File
@@ -6,6 +6,7 @@ export interface PaneContextValue {
serverId: string;
workspaceId: string;
tabId: string;
isPaneFocused: boolean;
target: WorkspaceTabTarget;
openTab(target: WorkspaceTabTarget): void;
closeCurrentTab(): void;
+47 -11
View File
@@ -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(
File diff suppressed because it is too large Load Diff
@@ -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
+7 -5
View File
@@ -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;
@@ -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<string, Agent>([[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<string, Agent>([[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<string, Agent>(),
knownAgentIds: new Set<string>(),
})
).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<string, Agent>([
[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<string, Agent>([
[
@@ -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<string>(), knownAgentIds: new Set<string>() };
const b = { activeAgentIds: new Set<string>(), knownAgentIds: new Set<string>() };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
});
});
@@ -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<string>;
knownAgentIds: Set<string>;
}
export function deriveWorkspaceAgentVisibility(input: {
sessionAgents: Map<string, Agent> | undefined;
workspaceId: string;
}): {
visibleAgents: Agent[];
lookupById: Map<string, Agent>;
} {
}): WorkspaceAgentVisibility {
const { sessionAgents, workspaceId } = input;
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
if (!sessionAgents || !workspaceId) {
return {
visibleAgents: [],
lookupById: new Map<string, Agent>(),
activeAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
}
const lookupById = new Map<string, Agent>();
const visible: Agent[] = [];
const activeAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
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<string>, b: Set<string>): 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<string, Agent>;
knownAgentIds: Set<string>;
}): 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<string, Agent>;
knownAgentIds: Set<string>;
}): 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);
}
@@ -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 {
@@ -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,
@@ -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 <View style={{ flex: 1 }} />;
}
return (
<ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
@@ -539,7 +566,6 @@ function WorkspaceScreenContent({
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast();
const isScreenFocused = useIsFocused();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -555,20 +581,18 @@ function WorkspaceScreenContent({
});
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const sessionAgents = useSessionStore(
(state) => 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<string>());
const reopenedArchivedOpenIntentsRef = useRef(new Set<string>());
const pendingCloseTabIdsRef = useRef(new Set<string>());
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(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 (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? (
<WorkspaceTabPresentationResolver
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{(presentation) => (
<WorkspaceDocumentTitleEffect
label={presentation.label}
titleState={presentation.titleState}
/>
)}
</WorkspaceTabPresentationResolver>
) : null}
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
<ScreenHeader
@@ -2115,13 +2140,14 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabsInPane}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
buildPaneContentModel={({ paneId, tab }) =>
buildPaneContentModel({
tab,
paneId,
focusPaneBeforeOpen: true,
})
}
buildPaneContentModel={({ paneId, tab, isPaneFocused }) =>
buildPaneContentModel({
tab,
paneId,
isPaneFocused,
focusPaneBeforeOpen: true,
})
}
onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId);
}}
+4 -1
View File
@@ -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
@@ -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: [
"<task-notification>",
"<task-id>bg-queue-1</task-id>",
"<status>completed</status>",
"<summary>Background task completed</summary>",
"<output-file>/tmp/bg-queue-1.txt</output-file>",
"</task-notification>",
].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",
@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, 250));
private async waitForLiveHistoryPoll(): Promise<void> {
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<string, unknown>;
try {
entry = JSON.parse(trimmed) as Record<string, unknown>;
} 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<string, unknown>): 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<string, unknown>
): 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) {
@@ -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<AgentStreamEvent>,
timeoutMs: number
): Promise<AgentStreamEvent> {
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: [
"<task-notification>",
"<task-id>appended-bg-1</task-id>",
"<status>completed</status>",
`<summary>${APPENDED_TASK_NOTIFICATION_MARKER}</summary>`,
"<output-file>/tmp/appended-bg-1.txt</output-file>",
"</task-notification>",
].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<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification"
);
const sawAssistant = appendedEvents.some(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
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<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification"
);
const turnStartedEvent = appendedEvents.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_started" }> =>
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 });
@@ -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<typeof TaskNotificationUserContentSchema>;
type TaskNotificationSystemRecord = z.infer<typeof TaskNotificationSystemRecordSchema>;
type TaskNotificationHistoryRecord = z.infer<typeof TaskNotificationHistoryRecordSchema>;
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 } : {}),
};
}
+1 -1
View File
@@ -1723,7 +1723,7 @@ export class Session {
payload: {
requestId,
requestType: msg.type,
error: 'Request failed',
error: `Request failed: ${err.message}`,
code: 'handler_error',
},
})
+28 -20
View File
@@ -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,
});