From 23aaecd99a6bdf5e0702470b2ccb5592df00eda8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 17:22:17 +0700 Subject: [PATCH 1/9] fix(server): include actual error details in DaemonRpcError messages The catch-all handler was replacing real error messages with "Request failed", and DaemonRpcError only showed the error string without requestType or code context. --- packages/server/src/client/daemon-client.ts | 5 ++++- packages/server/src/server/session.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2fd230f24..09fd1f30d 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -328,7 +328,10 @@ class DaemonRpcError extends Error { readonly code?: string constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { - super(params.error) + const parts = [params.error] + if (params.requestType) parts.push(`requestType=${params.requestType}`) + if (params.code) parts.push(`code=${params.code}`) + super(parts.join(' ')) this.name = 'DaemonRpcError' this.requestId = params.requestId this.requestType = params.requestType diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ae5e1c52..2f51d3ed7 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1723,7 +1723,7 @@ export class Session { payload: { requestId, requestType: msg.type, - error: 'Request failed', + error: `Request failed: ${err.message}`, code: 'handler_error', }, }) From 866aeb8ad9cc74e8cf2b8082f284f3ab7c96475c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 17:29:46 +0700 Subject: [PATCH 2/9] fix(app): extract favicon sync and add freezeOnBlur to screens --- packages/app/src/app/_layout.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 34cde2bd7..bd72d47f5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -430,6 +430,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { + {children} ); @@ -478,7 +479,6 @@ function OfferLinkListener({ function AppWithSidebar({ children }: { children: ReactNode }) { const pathname = usePathname(); const params = useGlobalSearchParams<{ open?: string | string[] }>(); - useFaviconStatus(); const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const shouldShowAppChrome = activeServerId !== null; @@ -508,6 +508,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) { ); } +function FaviconStatusSync() { + useFaviconStatus(); + return null; +} + function NavigationActiveWorkspaceObserver() { const navigationRef = useNavigationContainerRef(); @@ -608,10 +613,13 @@ export default function RootLayout() { > - + From 614c085310b75a829c4eeb33769cc138a5c4ff2e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 18:26:36 +0700 Subject: [PATCH 3/9] fix(app): reduce unnecessary re-renders in sidebar and workspace screens - Gate WorkspaceScreen on useIsFocused to prevent background rendering - Stabilize SidebarAnimationProvider context with useCallback/useMemo - Wrap LeftSidebar in memo and memoize gesture/styles - Extract inline style objects in AppContainer to stable references --- packages/app/src/app/_layout.tsx | 14 +- packages/app/src/components/left-sidebar.tsx | 171 ++++++++++-------- .../contexts/sidebar-animation-context.tsx | 35 ++-- .../screens/workspace/workspace-screen.tsx | 11 +- 4 files changed, 137 insertions(+), 94 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index bd72d47f5..c9a0a7010 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -252,6 +252,9 @@ function QueryProvider({ children }: { children: ReactNode }) { return {children}; } +const rowStyle = { flex: 1, flexDirection: "row" } as const; +const flexStyle = { flex: 1 } as const; + interface AppContainerProps { children: ReactNode; selectedAgentId?: string; @@ -377,11 +380,16 @@ function AppContainer({ ] ); + const containerStyle = useMemo( + () => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }), + [theme.colors.surface0] + ); + const content = ( - - + + {!isMobile && chromeEnabled && } - + {children} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 172959e9b..8256b58c5 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' +import { memo, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' import { View, Pressable, Text, Platform } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import Animated, { @@ -39,7 +39,7 @@ interface LeftSidebarProps { selectedAgentId?: string } -export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { +export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { const { theme } = useUnistyles() const insets = useSafeAreaInsets() const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' @@ -207,76 +207,103 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr ) // 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 closeGesture = useMemo( + () => + 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 deltaX = touch.absoluteX - closeTouchStartX.value - const deltaY = touch.absoluteY - closeTouchStartY.value - const absDeltaX = Math.abs(deltaX) - const absDeltaY = Math.abs(deltaY) + const deltaX = touch.absoluteX - closeTouchStartX.value + const deltaY = touch.absoluteY - closeTouchStartY.value + const absDeltaX = Math.abs(deltaX) + const absDeltaY = Math.abs(deltaY) - // 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 - } + // 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 + } - // 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 - }) + // 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 + }), + [ + isOpen, + closeGestureRef, + closeTouchStartX, + closeTouchStartY, + isGesturing, + isMobile, + windowWidth, + translateX, + backdropOpacity, + animateToClose, + animateToOpen, + handleClose, + ] + ) + + const mobileSidebarInsetStyle = useMemo( + () => ({ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom }), + [windowWidth, insets.top, insets.bottom] + ) + + const hostStatusDotStyle = useMemo( + () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], + [activeHostStatusColor] + ) const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], @@ -303,7 +330,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > - + {activeHostLabel} @@ -568,7 +595,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr ) -} +}) const styles = StyleSheet.create((theme) => ({ backdrop: { diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 59203e697..eed2a16a0 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; import { useWindowDimensions } from "react-native"; import { useSharedValue, @@ -78,7 +78,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) } }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); - const animateToOpen = () => { + const animateToOpen = useCallback(() => { "worklet"; translateX.value = withTiming(0, { duration: ANIMATION_DURATION, @@ -88,9 +88,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }; + }, [translateX, backdropOpacity]); - const animateToClose = () => { + const animateToClose = useCallback(() => { "worklet"; translateX.value = withTiming(-windowWidth, { duration: ANIMATION_DURATION, @@ -100,20 +100,23 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }; + }, [translateX, backdropOpacity, windowWidth]); + + const value = useMemo( + () => ({ + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + closeGestureRef, + }), + [translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef] + ); return ( - + {children} ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 274536eec..e9fefa0b2 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -519,6 +519,12 @@ export function WorkspaceScreen({ workspaceId, openIntent, }: WorkspaceScreenProps) { + const isFocused = useIsFocused(); + + if (!isFocused) { + return ; + } + return ( isScreenFocused, + isActive: () => true, handle: handleWorkspaceTabAction, }); @@ -1794,7 +1799,7 @@ function WorkspaceScreenContent({ ] as const, enabled: Boolean(normalizedServerId && normalizedWorkspaceId), priority: 100, - isActive: () => isScreenFocused, + isActive: () => true, handle: handleWorkspacePaneAction, }); From 584f5ce05e348f1fde8104ef5d0944cebc975869 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 18:34:52 +0700 Subject: [PATCH 4/9] refactor(app): extract agent panel content --- packages/app/src/panels/agent-panel.tsx | 935 ++++++++++++- .../src/screens/agent/agent-ready-screen.tsx | 1160 ----------------- .../screens/workspace/workspace-screen.tsx | 39 + 3 files changed, 969 insertions(+), 1165 deletions(-) delete mode 100644 packages/app/src/screens/agent/agent-ready-screen.tsx diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index cddc2ae3c..519c61f29 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,12 +1,51 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useFocusEffect, useIsFocused } from "@react-navigation/native"; +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 { + useHostRuntimeSession, + 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") { @@ -66,11 +105,9 @@ function AgentPanel() { const { serverId, target, openFileInWorkspace } = usePaneContext(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( - { openFileInWorkspace(filePath); }} @@ -83,3 +120,891 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = { component: AgentPanel, useDescriptor: useAgentPanelDescriptor, }; + +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +function logWebStickyBottom( + _event: string, + _details: Record +): void {} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function isNotFoundErrorMessage(message: string): boolean { + return /agent not found|not found/i.test(message); +} + +function AgentPanelContent({ + serverId, + agentId, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId: string; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const resolvedAgentId = agentId.trim() || undefined; + const resolvedServerId = serverId.trim() || undefined; + const daemons = useHosts(); + const runtimeServerId = resolvedServerId ?? ""; + const { + snapshot: runtimeSnapshot, + client: runtimeClient, + isConnected: runtimeIsConnected, + } = useHostRuntimeSession(runtimeServerId); + + const connectionServerId = resolvedServerId ?? null; + const daemon = connectionServerId + ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null + : null; + const serverLabel = + daemon?.label ?? connectionServerId ?? "Selected host"; + const isUnknownDaemon = Boolean(connectionServerId && !daemon); + const connectionStatus: HostRuntimeConnectionStatus = + runtimeSnapshot?.connectionStatus ?? + (isUnknownDaemon ? "offline" : "connecting"); + const lastConnectionError = runtimeSnapshot?.lastError ?? null; + + if (!resolvedServerId || !runtimeClient) { + return ( + + ); + } + + return ( + + ); +} + +function AgentPanelBody({ + serverId, + agentId, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + client: NonNullable["client"]>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const { theme } = useUnistyles(); + const panelToast = useToastHost(); + const { isArchivingAgent } = useArchiveAgent(); + const streamViewRef = useRef(null); + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + const isScreenFocused = useIsFocused(); + const reconnectToastArmedRef = useRef(false); + const initAttemptTokenRef = useRef(0); + const routeBottomAnchorRequestRef = useRef<{ + routeKey: string; + reason: "initial-entry" | "resume"; + } | null>(null); + + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + + const handleAddImagesCallback = useCallback( + (addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, + [] + ); + + const agent = useSessionStore((state) => + agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined + ); + const streamItemsRaw = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentStreamTail?.get(agentId) + : undefined + ); + const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); + const markPendingCreateLifecycle = useCreateFlowStore( + (state) => state.markLifecycle + ); + const clearPendingCreate = useCreateFlowStore((state) => state.clear); + const isInitializingFromMap = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false + : false + ); + const historySyncGeneration = useSessionStore( + (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 + ); + const hasAppliedAuthoritativeHistory = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === + true + : false + ); + const agentHistorySyncGeneration = useSessionStore((state) => + agentId + ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1 + : -1 + ); + const allPendingPermissions = useSessionStore( + (state) => state.sessions[serverId]?.pendingPermissions + ); + const setAgents = useSessionStore((state) => state.setAgents); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setPendingPermissions = useSessionStore( + (state) => state.setPendingPermissions + ); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const { ensureAgentIsInitialized } = useAgentInitialization({ + serverId, + client: hasSession ? client : null, + }); + const [missingAgentState, setMissingAgentState] = useState({ + kind: "idle", + }); + + const pendingCreate = useMemo(() => { + if (!agentId) { + return null; + } + const values = Object.values(pendingByDraftId); + for (const entry of values) { + if ( + entry.lifecycle === "active" && + entry.serverId === serverId && + entry.agentId === agentId + ) { + return entry; + } + } + return null; + }, [agentId, pendingByDraftId, serverId]); + const isPendingCreateForPanel = Boolean(pendingCreate); + const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; + + const pendingPermissions = useMemo(() => { + if (!allPendingPermissions || !agentId) { + return new Map(); + } + const filtered = new Map(); + for (const [key, permission] of allPendingPermissions) { + if (permission.agentId === agentId) { + filtered.set(key, permission); + } + } + return filtered; + }, [agentId, allPendingPermissions]); + + const setFocusedAgentId = useCallback( + (focusedAgentId: string | null) => { + useSessionStore.getState().setFocusedAgentId(serverId, focusedAgentId); + }, + [serverId] + ); + + const attentionController = useAgentAttentionClear({ + agentId, + client, + isConnected, + requiresAttention: agent?.requiresAttention, + attentionReason: agent?.attentionReason, + isScreenFocused, + }); + + const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ + mode: "translate", + }); + + const handleHistorySyncFailure = useCallback( + ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { + if (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]); + + useFocusEffect( + useCallback(() => { + if (!agentId || !isConnected || !hasSession) { + return; + } + ensureInitializedWithSyncErrorHandling("focus"); + }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected]) + ); + + const isArchivingCurrentAgent = Boolean( + agentId && + isArchivingAgent({ serverId, agentId }) + ); + + useFocusEffect( + useCallback(() => { + if (!agentId) { + setFocusedAgentId(null); + return; + } + + setFocusedAgentId(agentId); + return () => { + attentionController.clearOnAgentBlur(); + setFocusedAgentId(null); + }; + }, [agentId, attentionController, setFocusedAgentId]) + ); + + const isInitializing = agentId ? isInitializingFromMap !== false : false; + const isHistorySyncing = useMemo(() => { + if (!agentId || !isInitializing) { + return false; + } + const initKey = getInitKey(serverId, agentId); + return Boolean(getInitDeferred(initKey)); + }, [agentId, isInitializing, serverId]); + const needsAuthoritativeSync = useMemo(() => { + if (!agentId) { + return false; + } + return agentHistorySyncGeneration < historySyncGeneration; + }, [agentHistorySyncGeneration, agentId, historySyncGeneration]); + + const optimisticStreamItems = useMemo(() => { + if (!isPendingCreateForPanel || !pendingCreate) { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: pendingCreate.clientMessageId, + text: pendingCreate.text, + timestamp: new Date(pendingCreate.timestamp), + ...(pendingCreate.images && pendingCreate.images.length > 0 + ? { images: pendingCreate.images } + : {}), + }, + ]; + }, [isPendingCreateForPanel, pendingCreate]); + + const mergedStreamItems = useMemo(() => { + if (optimisticStreamItems.length === 0) { + return streamItems; + } + const optimistic = optimisticStreamItems[0]; + if (!optimistic) { + return streamItems; + } + const alreadyHasOptimistic = streamItems.some( + (item) => item.kind === "user_message" && item.id === optimistic.id + ); + return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; + }, [optimisticStreamItems, streamItems]); + + const shouldUseOptimisticStream = + isPendingCreateForPanel && optimisticStreamItems.length > 0; + const authoritativeStatus = agent?.status; + const isAuthoritativeBootstrapping = + authoritativeStatus === "initializing" || authoritativeStatus === "idle"; + const showPendingCreateSubmitLoading = + isPendingCreateForPanel && + (!authoritativeStatus || isAuthoritativeBootstrapping); + const canFinalizePendingCreate = + Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; + + const placeholderAgent: Agent | null = useMemo(() => { + if (!shouldUseOptimisticStream || !agentId) { + return null; + } + const now = new Date(); + return { + serverId, + id: agentId, + provider: "claude", + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "claude", + sessionId: null, + model: null, + modeId: null, + }, + title: "Agent", + cwd: ".", + model: null, + labels: {}, + }; + }, [agentId, serverId, shouldUseOptimisticStream]); + + const viewState = useAgentScreenStateMachine({ + routeKey: `${serverId}:${agentId ?? ""}`, + input: { + agent: agent ?? null, + placeholderAgent, + missingAgentState, + isConnected, + isArchivingCurrentAgent, + isHistorySyncing, + needsAuthoritativeSync, + shouldUseOptimisticStream, + hasHydratedHistoryBefore, + }, + }); + + const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; + const routeEntryKey = agentId ? `${serverId}:${agentId}` : null; + routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ + cachedIntent: routeBottomAnchorRequestRef.current, + routeKey: routeEntryKey, + hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, + }); + const routeBottomAnchorRequest = useMemo( + () => + deriveRouteBottomAnchorRequest({ + intent: routeBottomAnchorRequestRef.current, + effectiveAgentId: effectiveAgent?.id ?? null, + }), + [effectiveAgent?.id] + ); + + useEffect(() => { + if (!isPendingCreateForPanel || !pendingCreate) { + return; + } + const hasUserMessage = streamItems.some( + (item) => + item.kind === "user_message" && + item.id === pendingCreate.clientMessageId + ); + if (hasUserMessage && canFinalizePendingCreate) { + if ( + agentId && + pendingCreate.images && + pendingCreate.images.length > 0 + ) { + setAgentStreamTail(serverId, (previous) => { + const current = previous.get(agentId); + if (!current) { + return previous; + } + + const merged = mergePendingCreateImages({ + streamItems: current, + clientMessageId: pendingCreate.clientMessageId, + images: pendingCreate.images, + }); + if (merged === current) { + return previous; + } + + const next = new Map(previous); + next.set(agentId, merged); + return next; + }); + } + markPendingCreateLifecycle({ + draftId: pendingCreate.draftId, + lifecycle: "sent", + }); + clearPendingCreate({ draftId: pendingCreate.draftId }); + } + }, [ + agentId, + canFinalizePendingCreate, + clearPendingCreate, + isPendingCreateForPanel, + markPendingCreateLifecycle, + pendingCreate, + serverId, + setAgentStreamTail, + streamItems, + ]); + + useEffect(() => { + if (!agentId) { + return; + } + if (!isConnected || !hasSession) { + return; + } + const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; + if (!shouldSyncOnEntry) { + return; + } + + ensureInitializedWithSyncErrorHandling("entry"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + needsAuthoritativeSync, + ]); + + useEffect(() => { + initAttemptTokenRef.current += 1; + setMissingAgentState({ kind: "idle" }); + }, [agentId, serverId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (agent || shouldUseOptimisticStream) { + if (missingAgentState.kind !== "idle") { + setMissingAgentState({ kind: "idle" }); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { + return; + } + + setMissingAgentState({ kind: "resolving" }); + const attemptToken = ++initAttemptTokenRef.current; + + ensureAgentIsInitialized(agentId) + .then(async () => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const currentAgent = useSessionStore + .getState() + .sessions[serverId] + ?.agents.get(agentId); + if (!currentAgent) { + const result = await client.fetchAgent(agentId); + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + if (!result) { + setMissingAgentState({ + kind: "not_found", + message: `Agent not found: ${agentId}`, + }); + return; + } + const normalized = normalizeAgentSnapshot(result.agent, serverId); + const hydrated = { + ...normalized, + projectPlacement: result.project, + }; + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + setPendingPermissions(serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next.entries()) { + if (pending.agentId === hydrated.id) { + next.delete(key); + } + } + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); + } + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + setMissingAgentState({ kind: "idle" }); + }) + .catch((error) => { + if (attemptToken !== initAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setMissingAgentState({ kind: "not_found", message }); + return; + } + setMissingAgentState({ kind: "error", message }); + }); + }, [ + agent, + agentId, + client, + ensureAgentIsInitialized, + hasSession, + isConnected, + missingAgentState.kind, + serverId, + setAgents, + setPendingPermissions, + shouldUseOptimisticStream, + ]); + + const isHistoryRefreshCatchingUp = + viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "toast"; + const shouldEmitSyncErrorToast = + viewState.tag === "ready" && + viewState.sync.status === "sync_error" && + viewState.sync.shouldEmitSyncErrorToast; + + useDelayedHistoryRefreshToast({ + isCatchingUp: isHistoryRefreshCatchingUp, + indicatorColor: theme.colors.primary, + showToast: panelToast.api.show, + }); + + useEffect(() => { + if (!shouldEmitSyncErrorToast) { + return; + } + panelToast.api.error("Failed to refresh agent. Retrying in background."); + }, [panelToast.api, shouldEmitSyncErrorToast]); + + if (viewState.tag === "not_found") { + return ( + + + Agent not found + + + ); + } + + if (viewState.tag === "error") { + return ( + + + Failed to load agent + {viewState.message} + + + ); + } + + if (viewState.tag === "boot" || !effectiveAgent) { + return ( + + + + + + ); + } + + return ( + + + + + + + + + + {agentId && !isArchivingCurrentAgent ? ( + { + logWebStickyBottom("screen_composer_height_change", { + agentId, + height, + }); + streamViewRef.current?.prepareForViewportChange(); + }} + onMessageSent={() => { + logWebStickyBottom("screen_message_sent_scroll_to_bottom", { + agentId, + }); + streamViewRef.current?.scrollToBottom("message-sent"); + }} + /> + ) : null} + + {viewState.tag === "ready" && + viewState.sync.status === "catching_up" && + viewState.sync.ui === "overlay" ? ( + + + + ) : null} + + + + + + {isArchivingCurrentAgent ? ( + + + Archiving agent... + + Please wait while we archive this agent. + + + ) : null} + + ); +} + +function AgentSessionUnavailableState({ + serverLabel, + connectionStatus, + lastError, + isUnknownDaemon = false, +}: { + serverLabel: string; + connectionStatus: HostRuntimeConnectionStatus; + lastError: string | null; + isUnknownDaemon?: boolean; +}) { + if (isUnknownDaemon) { + return ( + + + + Cannot open this agent because {serverLabel} is not configured on + this device. + + + Add the host in Settings or open an agent on a configured server to + continue. + + + + ); + } + + const isConnecting = connectionStatus === "connecting"; + const isPreparingSession = connectionStatus === "online"; + + return ( + + + {isConnecting || isPreparingSession ? ( + <> + + + {isPreparingSession + ? `Preparing ${serverLabel} session...` + : `Connecting to ${serverLabel}...`} + + + {isPreparingSession + ? "We will show this agent in a moment." + : "We will show this agent once the host is online."} + + + ) : ( + <> + + Reconnecting to {serverLabel}... + + + We will show this agent again as soon as the host is reachable. + + {lastError ? ( + {lastError} + ) : null} + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + root: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + flex: 1, + overflow: "hidden", + }, + content: { + flex: 1, + }, + historySyncOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: theme.colors.surface0, + alignItems: "center", + justifyContent: "center", + zIndex: 40, + }, + archivingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: "rgba(8, 10, 14, 0.86)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[8], + gap: theme.spacing[3], + zIndex: 50, + }, + archivingTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + archivingSubtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + loadingText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + gap: theme.spacing[3], + }, + errorContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + fontSize: theme.fontSize.lg, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + statusText: { + marginTop: theme.spacing[2], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + offlineTitle: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + offlineDescription: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + offlineDetails: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, +})); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx deleted file mode 100644 index 324dd6c72..000000000 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ /dev/null @@ -1,1160 +0,0 @@ -import { useEffect, useMemo, useRef, useCallback, useState } from "react"; -import { - View, - Text, - ActivityIndicator, - Platform, - BackHandler, -} from "react-native"; -import { useFocusEffect, useIsFocused } from "@react-navigation/native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useQueryClient } from "@tanstack/react-query"; -import ReanimatedAnimated from "react-native-reanimated"; -import { GestureDetector } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { ToastViewport, useToastHost } from "@/components/toast-host"; -import { ExplorerSidebar } from "@/components/explorer-sidebar"; -import { FileDropZone } from "@/components/file-drop-zone"; -import type { ImageAttachment } from "@/components/message-input"; -import { - ExplorerSidebarAnimationProvider, -} from "@/contexts/explorer-sidebar-animation-context"; -import { usePanelStore } from "@/stores/panel-store"; -import { useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { - useHostRuntimeSession, - type HostRuntimeConnectionStatus, -} from "@/runtime/host-runtime"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import type { Agent } from "@/contexts/session-context"; -import type { StreamItem } from "@/types/stream"; -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, - useCheckoutStatusQuery, -} from "@/hooks/use-checkout-status-query"; -import { useAgentInitialization } from "@/hooks/use-agent-initialization"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { - useAgentScreenStateMachine, - type AgentScreenMissingState, -} from "@/hooks/use-agent-screen-state-machine"; -import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; -import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; -import { - derivePendingPermissionKey, - normalizeAgentSnapshot, -} from "@/utils/agent-snapshots"; -import { mergePendingCreateImages } from "@/utils/pending-create-images"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; -import type { DaemonClient } from "@server/client/daemon-client"; -import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; -import type { ExplorerCheckoutContext } from "@/stores/panel-store"; -import { - deriveRouteBottomAnchorIntent, - deriveRouteBottomAnchorRequest, -} from "./agent-ready-screen-bottom-anchor"; - -const EMPTY_STREAM_ITEMS: StreamItem[] = []; -function logWebStickyBottom( - _event: string, - _details: Record -): void { - // Intentionally disabled: this path is too noisy during voice debugging. -} - -export function AgentReadyScreen({ - serverId, - agentId, - showExplorerSidebar = true, - wrapWithExplorerSidebarProvider = true, - onOpenWorkspaceFile, -}: { - serverId: string; - agentId: string; - showExplorerSidebar?: boolean; - wrapWithExplorerSidebarProvider?: boolean; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; -}) { - const resolvedAgentId = agentId?.trim() || undefined; - const resolvedServerId = serverId?.trim() || undefined; - const daemons = useHosts(); - const runtimeServerId = resolvedServerId ?? ""; - const { - snapshot: runtimeSnapshot, - client: runtimeClient, - isConnected: runtimeIsConnected, - } = useHostRuntimeSession(runtimeServerId); - - const connectionServerId = resolvedServerId ?? null; - const daemon = connectionServerId - ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null - : null; - const serverLabel = - daemon?.label ?? connectionServerId ?? "Selected host"; - const isUnknownDaemon = Boolean(connectionServerId && !daemon); - const connectionStatus: HostRuntimeConnectionStatus = - runtimeSnapshot?.connectionStatus ?? - (isUnknownDaemon ? "offline" : "connecting"); - const lastConnectionError = runtimeSnapshot?.lastError ?? null; - const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); - - if (!resolvedServerId || !runtimeClient) { - return ( - - ); - } - - const content = ( - - ); - - if (!wrapWithExplorerSidebarProvider) { - return content; - } - - return {content}; -} - -type AgentScreenContentProps = { - serverId: string; - agentId?: string; - client: DaemonClient; - isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; - showExplorerSidebar: boolean; - onOpenWorkspaceFile?: (input: { filePath: string }) => void; -}; - -function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function isNotFoundErrorMessage(message: string): boolean { - return /agent not found|not found/i.test(message); -} - -function AgentScreenContent({ - serverId, - agentId, - client, - isConnected, - connectionStatus, - showExplorerSidebar, - onOpenWorkspaceFile, -}: AgentScreenContentProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const queryClient = useQueryClient(); - const panelToast = useToastHost(); - const resolvedAgentId = agentId; - const { isArchivingAgent } = useArchiveAgent(); - - const streamViewRef = useRef(null); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files); - }, []); - - const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, []); - - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const openFileExplorer = usePanelStore((state) => state.openFileExplorer); - const closeToAgent = usePanelStore((state) => state.closeToAgent); - const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); - const activateExplorerTabForCheckout = usePanelStore( - (state) => state.activateExplorerTabForCheckout - ); - - // Derive isExplorerOpen from the unified panel state - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - // Select only the specific agent - const agent = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agents?.get(resolvedAgentId) - : undefined - ); - // Checkout status for header subtitle + git fallback when cached project placement is absent - const checkoutStatusQuery = useCheckoutStatusQuery({ - serverId, - cwd: agent?.cwd ?? "", - }); - const checkout = checkoutStatusQuery.status; - const resolveCachedCheckoutIsGit = useCallback( - (params: { - cwd?: string | null; - projectPlacementIsGit?: boolean; - checkoutStatusIsGit?: boolean; - }): boolean | null => { - if (typeof params.projectPlacementIsGit === "boolean") { - return params.projectPlacementIsGit; - } - - const cwd = params.cwd?.trim(); - if (!cwd) { - return null; - } - const cachedCheckout = queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd) - ); - if (typeof cachedCheckout?.isGit === "boolean") { - return cachedCheckout.isGit; - } - if (typeof params.checkoutStatusIsGit === "boolean") { - return params.checkoutStatusIsGit; - } - return null; - }, - [queryClient, serverId] - ); - const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => { - if (!resolvedAgentId) { - return null; - } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents?.get(resolvedAgentId); - const cwd = currentAgent?.cwd?.trim(); - const isGit = resolveCachedCheckoutIsGit({ - cwd, - projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit, - checkoutStatusIsGit: checkout?.isGit, - }); - if (!cwd || typeof isGit !== "boolean") { - return null; - } - return { serverId, cwd, isGit }; - }, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]); - const openExplorerForActiveCheckout = useCallback(() => { - const checkoutContext = resolveCurrentExplorerCheckout(); - if (checkoutContext) { - activateExplorerTabForCheckout(checkoutContext); - } - openFileExplorer(); - }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); - // Swipe-left gesture to open explorer sidebar on mobile - const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && mobileView === "agent", - onOpen: openExplorerForActiveCheckout, - }); - - // Handle hardware back button - close explorer sidebar first, then navigate back - useEffect(() => { - if (Platform.OS === "web") return; - - const handler = BackHandler.addEventListener("hardwareBackPress", () => { - if (isExplorerOpen) { - closeToAgent(); - return true; // Prevent default back navigation - } - return false; // Let default back navigation happen - }); - - return () => handler.remove(); - }, [isExplorerOpen, closeToAgent]); - - const activeExplorerCheckout = useMemo(() => { - const cwd = agent?.cwd?.trim(); - const isGit = resolveCachedCheckoutIsGit({ - cwd, - projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit, - checkoutStatusIsGit: checkout?.isGit, - }); - if (!cwd || typeof isGit !== "boolean") { - return null; - } - return { serverId, cwd, isGit }; - }, [ - agent?.cwd, - agent?.projectPlacement?.checkout?.isGit, - resolveCachedCheckoutIsGit, - resolvedAgentId, - checkout?.isGit, - serverId, - ]); - - useEffect(() => { - setActiveExplorerCheckout(activeExplorerCheckout); - }, [activeExplorerCheckout, setActiveExplorerCheckout]); - - useEffect(() => { - if (!activeExplorerCheckout) { - return; - } - activateExplorerTabForCheckout(activeExplorerCheckout); - }, [activateExplorerTabForCheckout, activeExplorerCheckout]); - - useEffect(() => { - return () => { - setActiveExplorerCheckout(null); - }; - }, [setActiveExplorerCheckout]); - - // Select only the specific stream tail - use stable empty array to avoid infinite loop - const streamItemsRaw = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId) - : undefined - ); - const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; - - const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); - const markPendingCreateLifecycle = useCreateFlowStore( - (state) => state.markLifecycle - ); - const clearPendingCreate = useCreateFlowStore((state) => state.clear); - const pendingCreate = useMemo(() => { - if (!resolvedAgentId) { - return null; - } - const values = Object.values(pendingByDraftId); - for (const entry of values) { - if ( - entry.lifecycle === "active" && - entry.serverId === serverId && - entry.agentId === resolvedAgentId - ) { - return entry; - } - } - return null; - }, [pendingByDraftId, resolvedAgentId, serverId]); - const isPendingCreateForRoute = Boolean(pendingCreate); - - // Select only the specific initializing state - const isInitializingFromMap = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false - : false - ); - const historySyncGeneration = useSessionStore( - (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 - ); - const hasAppliedAuthoritativeHistory = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get( - resolvedAgentId - ) === true - : false - ); - const agentHistorySyncGeneration = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1 - : -1 - ); - const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; - - // Select raw pending permissions - filter with useMemo to avoid new Map on every render - const allPendingPermissions = useSessionStore( - (state) => state.sessions[serverId]?.pendingPermissions - ); - const setAgents = useSessionStore((state) => state.setAgents); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setPendingPermissions = useSessionStore( - (state) => state.setPendingPermissions - ); - const pendingPermissions = useMemo(() => { - if (!allPendingPermissions || !resolvedAgentId) return new Map(); - const filtered = new Map(); - for (const [key, perm] of allPendingPermissions) { - if (perm.agentId === resolvedAgentId) { - filtered.set(key, perm); - } - } - return filtered; - }, [allPendingPermissions, resolvedAgentId]); - - const hasSession = useSessionStore( - (state) => Boolean(state.sessions[serverId]) - ); - const isScreenFocused = useIsFocused(); - const { ensureAgentIsInitialized } = useAgentInitialization({ - serverId, - client: hasSession ? client : null, - }); - const [missingAgentState, setMissingAgentState] = useState({ - kind: "idle", - }); - const reconnectToastArmedRef = useRef(false); - const initAttemptTokenRef = useRef(0); - const routeBottomAnchorRequestRef = useRef<{ - routeKey: string; - reason: "initial-entry" | "resume"; - } | null>(null); - const setFocusedAgentId = useCallback( - (agentId: string | null) => { - useSessionStore.getState().setFocusedAgentId(serverId, agentId); - }, - [serverId] - ); - - const attentionController = useAgentAttentionClear({ - agentId: resolvedAgentId, - client, - isConnected, - requiresAttention: agent?.requiresAttention, - attentionReason: agent?.attentionReason, - isScreenFocused, - }); - - const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const handleHistorySyncFailure = useCallback( - ({ origin, error }: { origin: "focus" | "entry"; error: unknown }) => { - if (resolvedAgentId) { - console.warn("[AgentScreen] history sync failed", { - origin, - agentId: resolvedAgentId, - error, - }); - } - const message = toErrorMessage(error); - setMissingAgentState((prev) => { - if (prev.kind === "error" && prev.message === message) { - return prev; - } - return { kind: "error", message }; - }); - }, - [resolvedAgentId] - ); - - const ensureInitializedWithSyncErrorHandling = useCallback( - (origin: "focus" | "entry") => { - if (!resolvedAgentId) { - return; - } - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - handleHistorySyncFailure({ origin, error }); - }); - }, - [ensureAgentIsInitialized, handleHistorySyncFailure, resolvedAgentId] - ); - - useEffect(() => { - if (connectionStatus === "online") { - reconnectToastArmedRef.current = false; - return; - } - if (connectionStatus === "idle") { - return; - } - if (!reconnectToastArmedRef.current) { - reconnectToastArmedRef.current = true; - panelToast.api.show("Reconnecting...", { - durationMs: 2200, - testID: "agent-reconnecting-toast", - }); - } - }, [connectionStatus, panelToast.api]); - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [ - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - resolvedAgentId, - ]) - ); - - const isGitCheckout = activeExplorerCheckout?.isGit ?? false; - const isArchivingCurrentAgent = Boolean( - resolvedAgentId && - isArchivingAgent({ serverId, agentId: resolvedAgentId }) - ); - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId) { - setFocusedAgentId(null); - return; - } - - setFocusedAgentId(resolvedAgentId); - return () => { - attentionController.clearOnAgentBlur(); - setFocusedAgentId(null); - }; - }, [attentionController, resolvedAgentId, setFocusedAgentId]) - ); - - const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; - const isHistorySyncing = useMemo(() => { - if (!resolvedAgentId || !isInitializing) { - return false; - } - const initKey = getInitKey(serverId, resolvedAgentId); - return Boolean(getInitDeferred(initKey)); - }, [resolvedAgentId, isInitializing, serverId]); - const needsAuthoritativeSync = useMemo(() => { - if (!resolvedAgentId) { - return false; - } - return agentHistorySyncGeneration < historySyncGeneration; - }, [agentHistorySyncGeneration, historySyncGeneration, resolvedAgentId]); - - const optimisticStreamItems = useMemo(() => { - if (!isPendingCreateForRoute || !pendingCreate) { - return EMPTY_STREAM_ITEMS; - } - return [ - { - kind: "user_message", - id: pendingCreate.clientMessageId, - text: pendingCreate.text, - timestamp: new Date(pendingCreate.timestamp), - ...(pendingCreate.images && pendingCreate.images.length > 0 - ? { images: pendingCreate.images } - : {}), - }, - ]; - }, [isPendingCreateForRoute, pendingCreate]); - - const mergedStreamItems = useMemo(() => { - if (optimisticStreamItems.length === 0) { - return streamItems; - } - const optimistic = optimisticStreamItems[0]; - if (!optimistic) { - return streamItems; - } - const alreadyHasOptimistic = streamItems.some( - (item) => item.kind === "user_message" && item.id === optimistic.id - ); - return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; - }, [optimisticStreamItems, streamItems]); - - const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0; - const authoritativeStatus = agent?.status; - const isAuthoritativeBootstrapping = - authoritativeStatus === "initializing" || authoritativeStatus === "idle"; - const showPendingCreateSubmitLoading = - isPendingCreateForRoute && - (!authoritativeStatus || isAuthoritativeBootstrapping); - const canFinalizePendingCreate = - Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; - - const placeholderAgent: Agent | null = useMemo(() => { - if (!shouldUseOptimisticStream || !resolvedAgentId) { - return null; - } - const now = new Date(); - return { - serverId, - id: resolvedAgentId, - provider: "claude", - status: "running", - createdAt: now, - updatedAt: now, - lastUserMessageAt: now, - lastActivityAt: now, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: "claude", - sessionId: null, - model: null, - modeId: null, - }, - title: "Agent", - cwd: ".", - model: null, - labels: {}, - }; - }, [resolvedAgentId, serverId, shouldUseOptimisticStream]); - - const viewState = useAgentScreenStateMachine({ - routeKey: `${serverId}:${resolvedAgentId ?? ""}`, - input: { - agent: agent ?? null, - placeholderAgent, - missingAgentState, - isConnected, - isArchivingCurrentAgent, - isHistorySyncing, - needsAuthoritativeSync, - shouldUseOptimisticStream, - hasHydratedHistoryBefore, - }, - }); - - const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; - const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null; - routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({ - cachedIntent: routeBottomAnchorRequestRef.current, - routeKey: routeEntryKey, - hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory, - }); - const routeBottomAnchorRequest = useMemo( - () => - deriveRouteBottomAnchorRequest({ - intent: routeBottomAnchorRequestRef.current, - effectiveAgentId: effectiveAgent?.id ?? null, - }), - [effectiveAgent?.id] - ); - useEffect(() => { - if (!isPendingCreateForRoute || !pendingCreate) { - return; - } - const hasUserMessage = streamItems.some( - (item) => - item.kind === "user_message" && - item.id === pendingCreate.clientMessageId - ); - if (hasUserMessage && canFinalizePendingCreate) { - if ( - resolvedAgentId && - pendingCreate.images && - pendingCreate.images.length > 0 - ) { - setAgentStreamTail(serverId, (prev) => { - const current = prev.get(resolvedAgentId); - if (!current) { - return prev; - } - - const merged = mergePendingCreateImages({ - streamItems: current, - clientMessageId: pendingCreate.clientMessageId, - images: pendingCreate.images, - }); - if (merged === current) { - return prev; - } - - const next = new Map(prev); - next.set(resolvedAgentId, merged); - return next; - }); - } - markPendingCreateLifecycle({ draftId: pendingCreate.draftId, lifecycle: "sent" }); - clearPendingCreate({ draftId: pendingCreate.draftId }); - } - }, [ - canFinalizePendingCreate, - clearPendingCreate, - isPendingCreateForRoute, - markPendingCreateLifecycle, - pendingCreate, - resolvedAgentId, - serverId, - setAgentStreamTail, - streamItems, - ]); - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - - if (!isConnected || !hasSession) { - return; - } - // On native clients, daemon stream forwarding is focused-agent only, so switching - // agents can leave timeline gaps unless we explicitly pull timeline catch-up. - const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web"; - if (!shouldSyncOnEntry) { - return; - } - - ensureInitializedWithSyncErrorHandling("entry"); - }, [ - resolvedAgentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - needsAuthoritativeSync, - ]); - - useEffect(() => { - // Clear stale resolution state when route target changes. - initAttemptTokenRef.current += 1; - setMissingAgentState({ kind: "idle" }); - }, [serverId, resolvedAgentId]); - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - if (agent || shouldUseOptimisticStream) { - if (missingAgentState.kind !== "idle") { - setMissingAgentState({ kind: "idle" }); - } - return; - } - if (!isConnected || !hasSession) { - return; - } - if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") { - return; - } - - setMissingAgentState({ kind: "resolving" }); - const attemptToken = ++initAttemptTokenRef.current; - - ensureAgentIsInitialized(resolvedAgentId) - .then(async () => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents.get(resolvedAgentId); - if (!currentAgent && client) { - const result = await client.fetchAgent(resolvedAgentId); - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - if (!result) { - setMissingAgentState({ - kind: "not_found", - message: `Agent not found: ${resolvedAgentId}`, - }); - return; - } - const normalized = normalizeAgentSnapshot(result.agent, serverId); - const hydrated = { - ...normalized, - projectPlacement: result.project, - }; - setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(hydrated.id, hydrated); - return next; - }); - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - for (const [key, pending] of next.entries()) { - if (pending.agentId === hydrated.id) { - next.delete(key); - } - } - for (const request of hydrated.pendingPermissions) { - const key = derivePendingPermissionKey(hydrated.id, request); - next.set(key, { key, agentId: hydrated.id, request }); - } - return next; - }); - } - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - setMissingAgentState({ kind: "idle" }); - }) - .catch((error) => { - if (attemptToken !== initAttemptTokenRef.current) { - return; - } - const message = toErrorMessage(error); - if (isNotFoundErrorMessage(message)) { - setMissingAgentState({ kind: "not_found", message }); - return; - } - setMissingAgentState({ kind: "error", message }); - }); - }, [ - agent, - client, - ensureAgentIsInitialized, - hasSession, - isConnected, - missingAgentState.kind, - resolvedAgentId, - serverId, - setAgents, - setPendingPermissions, - shouldUseOptimisticStream, - ]); - - useEffect(() => { - if (Platform.OS !== "web") { - return; - } - const title = agent?.title || "Agent"; - document.title = title; - }, [agent?.title]); - - const isHistoryRefreshCatchingUp = - viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "toast"; - const shouldEmitSyncErrorToast = - viewState.tag === "ready" && - viewState.sync.status === "sync_error" && - viewState.sync.shouldEmitSyncErrorToast; - - useDelayedHistoryRefreshToast({ - isCatchingUp: isHistoryRefreshCatchingUp, - indicatorColor: theme.colors.primary, - showToast: panelToast.api.show, - }); - - useEffect(() => { - if (!shouldEmitSyncErrorToast) { - return; - } - panelToast.api.error("Failed to refresh agent. Retrying in background."); - }, [panelToast.api, shouldEmitSyncErrorToast]); - - if (viewState.tag === "not_found") { - return ( - - - Agent not found - - - ); - } - - if (viewState.tag === "error") { - return ( - - - Failed to load agent - {viewState.message} - - - ); - } - - if (viewState.tag === "boot" || !effectiveAgent) { - return ( - - - - - - ); - } - - const mainContent = ( - - - - {/* Content Area with Keyboard Animation */} - - - - - - - {/* Agent Input Area */} - {resolvedAgentId && !isArchivingCurrentAgent && ( - { - logWebStickyBottom("screen_composer_height_change", { - agentId: resolvedAgentId, - height, - }); - streamViewRef.current?.prepareForViewportChange(); - }} - onMessageSent={() => { - logWebStickyBottom("screen_message_sent_scroll_to_bottom", { - agentId: resolvedAgentId, - }); - streamViewRef.current?.scrollToBottom("message-sent"); - }} - /> - )} - - {viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "overlay" ? ( - - - - ) : null} - - - - - - - {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} - {showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? ( - - ) : null} - - {isArchivingCurrentAgent ? ( - - - Archiving agent... - - Please wait while we archive this agent. - - - ) : null} - - ); - - return ( - <> - {isMobile ? ( - - {mainContent} - - ) : ( - mainContent - )} - - {/* Mobile Explorer Sidebar Overlay */} - {showExplorerSidebar && isMobile && resolvedAgentId ? ( - - ) : null} - - ); -} - -function AgentSessionUnavailableState({ - serverLabel, - connectionStatus, - lastError, - isUnknownDaemon = false, -}: { - serverLabel: string; - connectionStatus: HostRuntimeConnectionStatus; - lastError: string | null; - isUnknownDaemon?: boolean; -}) { - if (isUnknownDaemon) { - return ( - - - - Cannot open this agent because {serverLabel} is not configured on - this device. - - - Add the host in Settings or open an agent on a configured server to - continue. - - - - ); - } - - const isConnecting = connectionStatus === "connecting"; - const isPreparingSession = connectionStatus === "online"; - - return ( - - - {isConnecting || isPreparingSession ? ( - <> - - - {isPreparingSession - ? `Preparing ${serverLabel} session...` - : `Connecting to ${serverLabel}...`} - - - {isPreparingSession - ? "We will show this agent in a moment." - : "We will show this agent once the host is online."} - - - ) : ( - <> - - Reconnecting to {serverLabel}... - - - We will show this agent again as soon as the host is reachable. - - {lastError ? ( - {lastError} - ) : null} - - )} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - outerContainer: { - flex: 1, - flexDirection: "row", - backgroundColor: theme.colors.surface0, - }, - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - contentContainer: { - flex: 1, - overflow: "hidden", - }, - content: { - flex: 1, - }, - historySyncOverlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: theme.colors.surface0, - alignItems: "center", - justifyContent: "center", - zIndex: 40, - }, - archivingOverlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: "rgba(8, 10, 14, 0.86)", - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[8], - gap: theme.spacing[3], - zIndex: 50, - }, - archivingTitle: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - archivingSubtitle: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - loadingText: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - }, - centerState: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[6], - gap: theme.spacing[3], - }, - errorContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - }, - errorText: { - fontSize: theme.fontSize.lg, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - statusText: { - marginTop: theme.spacing[2], - textAlign: "center", - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - errorDetails: { - marginTop: theme.spacing[1], - textAlign: "center", - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - offlineTitle: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - offlineDescription: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - offlineDetails: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, -})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e9fefa0b2..d8f714daf 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -279,6 +279,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, @@ -1804,6 +1823,12 @@ function WorkspaceScreenContent({ }); 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; @@ -1890,6 +1915,20 @@ function WorkspaceScreenContent({ return ( + {Platform.OS === "web" && activeTabDescriptor ? ( + + {(presentation) => ( + + )} + + ) : null} Date: Fri, 20 Mar 2026 18:44:57 +0700 Subject: [PATCH 5/9] Extract mobile sidebar and gesture wrappers --- packages/app/src/app/_layout.tsx | 194 ++++--- packages/app/src/components/left-sidebar.tsx | 529 ++++++++++++------- 2 files changed, 430 insertions(+), 293 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c9a0a7010..ddd192c8d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -268,23 +268,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, @@ -293,92 +282,6 @@ function AppContainer({ selectedAgentId, toggleFileExplorer, }); - const { - translateX, - backdropOpacity, - windowWidth, - animateToOpen, - animateToClose, - isGesturing, - } = useSidebarAnimation(); - - // Track initial touch position for manual activation - const touchStartX = useSharedValue(0); - - // 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]; - if (touch) { - touchStartX.value = touch.absoluteX; - } - }) - .onTouchesMove((event, stateManager) => { - const touch = event.changedTouches[0]; - if (!touch || event.numberOfTouches !== 1) return; - - 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(); - } - }) - .onStart(() => { - 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( - newTranslateX, - [-windowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ); - }) - .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(); - runOnJS(openAgentList)(); - } else { - animateToClose(); - } - }) - .onFinalize(() => { - isGesturing.value = false; - }), - [ - openGestureEnabled, - windowWidth, - translateX, - backdropOpacity, - animateToOpen, - animateToClose, - openAgentList, - mobileView, - isGesturing, - horizontalScroll?.isAnyScrolledRight, - touchStartX, - ] - ); const containerStyle = useMemo( () => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }), @@ -407,8 +310,103 @@ function AppContainer({ } return ( - + {content} + + ); +} + +function MobileGestureWrapper({ + children, + chromeEnabled, +}: { + children: ReactNode; + chromeEnabled: boolean; +}) { + const mobileView = usePanelStore((state) => state.mobileView); + const openAgentList = usePanelStore((state) => state.openAgentList); + const horizontalScroll = useHorizontalScrollOptional(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + } = useSidebarAnimation(); + const touchStartX = useSharedValue(0); + const openGestureEnabled = chromeEnabled && mobileView === "agent"; + + const openGesture = useMemo( + () => + Gesture.Pan() + .enabled(openGestureEnabled) + .manualActivation(true) + .failOffsetY([-10, 10]) + .onTouchesDown((event) => { + const touch = event.changedTouches[0]; + if (touch) { + touchStartX.value = touch.absoluteX; + } + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0]; + if (!touch || event.numberOfTouches !== 1) return; + + const deltaX = touch.absoluteX - touchStartX.value; + + if (horizontalScroll?.isAnyScrolledRight.value) { + stateManager.fail(); + return; + } + + if (deltaX > 15) { + stateManager.activate(); + } + }) + .onStart(() => { + isGesturing.value = true; + }) + .onUpdate((event) => { + const newTranslateX = Math.min(0, -windowWidth + event.translationX); + translateX.value = newTranslateX; + backdropOpacity.value = interpolate( + newTranslateX, + [-windowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isGesturing.value = false; + const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + if (shouldOpen) { + animateToOpen(); + runOnJS(openAgentList)(); + } else { + animateToClose(); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }), + [ + openGestureEnabled, + windowWidth, + translateX, + backdropOpacity, + animateToOpen, + animateToClose, + openAgentList, + isGesturing, + horizontalScroll?.isAnyScrolledRight, + touchStartX, + ] + ); + + return ( + + {children} ); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 8256b58c5..c89c37710 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,4 +1,15 @@ -import { memo, 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 { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window' import { Combobox } from '@/components/ui/combobox' @@ -34,12 +45,58 @@ import { import { useOpenProjectPicker } from '@/hooks/use-open-project-picker' const DESKTOP_SIDEBAR_WIDTH = 320 +type SidebarShortcutModel = ReturnType +type SidebarTheme = ReturnType['theme'] interface LeftSidebarProps { selectedAgentId?: string } +interface HostOption { + id: string + label: string + description: string +} + +interface SidebarSharedProps { + theme: SidebarTheme + activeServerId: string | null + activeHostLabel: string + activeHostStatusColor: string + hostOptions: HostOption[] + hostTriggerRef: RefObject + isHostPickerOpen: boolean + setIsHostPickerOpen: Dispatch> + projects: SidebarProjectEntry[] + isInitialLoad: boolean + isRevalidating: boolean + isManualRefresh: boolean + collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys'] + shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey'] + toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed'] + setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed'] + handleRefresh: () => void + handleHostSelect: (nextServerId: string) => void + handleOpenProject: () => void + handleSettings: () => void +} + +interface MobileSidebarProps extends SidebarSharedProps { + insetsTop: number + insetsBottom: number + isOpen: boolean + closeToAgent: () => void + handleViewMoreNavigate: () => void +} + +interface DesktopSidebarProps extends SidebarSharedProps { + isOpen: boolean + handleViewMore: () => void +} + export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { + void _selectedAgentId + const { theme } = useUnistyles() const insets = useSafeAreaInsets() const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' @@ -109,10 +166,9 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte })), [daemons, runtime, runtimeConnectionStatusSignature] ) - const hostTriggerRef = useRef(null) + const hostTriggerRef = useRef(null) const [isHostPickerOpen, setIsHostPickerOpen] = useState(false) - // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ @@ -121,21 +177,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte }) const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } = useSidebarShortcutModel(projects) - const { - translateX, - backdropOpacity, - windowWidth, - animateToOpen, - animateToClose, - isGesturing, - closeGestureRef, - } = useSidebarAnimation() - const dragHandlers = useTauriDragHandlers() - 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 const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte 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 const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte void openProjectPicker() }, [openProjectPicker]) - // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { if (!activeServerId) { return @@ -174,7 +210,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte 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 const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte 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,14 +236,118 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte [pathname] ) - // Close gesture (swipe left to close when sidebar is open) + const sharedProps = { + theme, + activeServerId, + activeHostLabel, + activeHostStatusColor, + hostOptions, + hostTriggerRef, + isHostPickerOpen, + setIsHostPickerOpen, + projects, + isInitialLoad, + isRevalidating, + isManualRefresh, + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + toggleProjectCollapsed, + setProjectCollapsed, + handleRefresh, + handleHostSelect, + } + + if (isMobile) { + return ( + + ) + } + + return ( + + ) +}) + +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) - // 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] @@ -235,7 +369,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte const absDeltaX = Math.abs(deltaX) const absDeltaY = Math.abs(deltaY) - // Fail quickly on clear rightward or vertical intent so child views keep control. if (deltaX >= 10) { stateManager.fail() return @@ -244,8 +377,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte stateManager.fail() return } - - // Activate only on intentional leftward movement. if (deltaX <= -15 && absDeltaX > absDeltaY) { stateManager.activate() } @@ -254,8 +385,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte 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( @@ -267,7 +396,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte }) .onEnd((event) => { isGesturing.value = false - if (!isMobile) return const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 if (shouldClose) { animateToClose() @@ -285,7 +413,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte closeTouchStartX, closeTouchStartY, isGesturing, - isMobile, windowWidth, translateX, backdropOpacity, @@ -296,8 +423,8 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte ) const mobileSidebarInsetStyle = useMemo( - () => ({ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom }), - [windowWidth, insets.top, insets.bottom] + () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), + [windowWidth, insetsTop, insetsBottom] ) const hostStatusDotStyle = useMemo( @@ -314,160 +441,174 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none', })) - // Render mobile sidebar - // On web, keep the overlay interactive only while the sidebar is open. - // This preserves swipe/scroll behavior without blocking taps when closed. const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none' - if (isMobile) { - return ( - - {/* Backdrop */} - - - - - - - {/* Header */} - - - - {({ hovered }) => ( - <> - - - Sessions - - - )} - - - + return ( + + + + - {/* Middle: scrollable project/workspace tree */} - {isInitialLoad ? ( - - ) : ( - - )} - - {/* Footer */} - - - [ - styles.hostTrigger, - hovered && styles.hostTriggerHovered, - ]} - onPress={() => setIsHostPickerOpen(true)} - disabled={hostOptions.length === 0} - > - - - {activeHostLabel} - - - - - - - - {({ hovered }) => ( - - )} - - - - - Add project - - - - - - {({ hovered }) => ( - + + + + + + {({ hovered }) => ( + <> + - )} - - - + + Sessions + + + )} + - - - - ) - } - // Desktop: no edge swipe, just show/hide based on isOpen + {isInitialLoad ? ( + + ) : ( + + )} + + + + [ + styles.hostTrigger, + hovered && styles.hostTriggerHovered, + ]} + onPress={() => setIsHostPickerOpen(true)} + disabled={hostOptions.length === 0} + > + + + {activeHostLabel} + + + + + + + + {({ hovered }) => ( + + )} + + + + + Add project + + + + + + {({ hovered }) => ( + + )} + + + + + + + + + ) +} + +function DesktopSidebar({ + theme, + activeServerId, + activeHostLabel, + activeHostStatusColor, + hostOptions, + hostTriggerRef, + isHostPickerOpen, + setIsHostPickerOpen, + projects, + isInitialLoad, + isRevalidating, + isManualRefresh, + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + toggleProjectCollapsed, + setProjectCollapsed, + handleRefresh, + handleHostSelect, + handleOpenProject, + handleSettings, + isOpen, + handleViewMore, +}: DesktopSidebarProps) { + const dragHandlers = useTauriDragHandlers() + const trafficLightPadding = useTrafficLightPadding() + const hostStatusDotStyle = useMemo( + () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], + [activeHostStatusColor] + ) + if (!isOpen) { return null } @@ -501,7 +642,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte - {/* Middle: scrollable project/workspace tree */} {isInitialLoad ? ( ) : ( @@ -517,7 +657,6 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte /> )} - {/* Footer */} {({ hovered }) => ( {({ hovered }) => ( ) -}) +} const styles = StyleSheet.create((theme) => ({ backdrop: { From e0f9b33d230d48a0b9871f8582616a205532f790 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 18:46:23 +0700 Subject: [PATCH 6/9] refactor(app): drive agent panel focus from panes --- .../app/src/components/split-container.tsx | 2 + packages/app/src/panels/agent-panel.tsx | 72 ++++++++++--------- packages/app/src/panels/pane-context.tsx | 1 + .../workspace/workspace-pane-content.tsx | 3 + .../screens/workspace/workspace-screen.tsx | 36 ++++++++-- 5 files changed, 74 insertions(+), 40 deletions(-) diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 75c56ebd0..23215f177 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -72,6 +72,7 @@ interface SplitContainerProps { newTabAgentOptionId?: "__new_tab_agent__"; buildPaneContentModel: (input: { paneId: string; + isPaneFocused: boolean; tab: WorkspaceTabDescriptor; }) => WorkspacePaneContentModel; onFocusPane: (paneId: string) => void; @@ -751,6 +752,7 @@ function SplitPaneView({ activeTabDescriptor ? buildPaneContentModel({ paneId: pane.id, + isPaneFocused: isFocused, tab: activeTabDescriptor, }) : null, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 519c61f29..2c4f545be 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, Platform, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -102,12 +101,13 @@ function useAgentPanelDescriptor( } function AgentPanel() { - const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( { openFileInWorkspace(filePath); }} @@ -142,10 +142,12 @@ function isNotFoundErrorMessage(message: string): boolean { function AgentPanelContent({ serverId, agentId, + isPaneFocused, onOpenWorkspaceFile, }: { serverId: string; agentId: string; + isPaneFocused: boolean; onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { const resolvedAgentId = agentId.trim() || undefined; @@ -185,6 +187,7 @@ function AgentPanelContent({ ["client"]>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; @@ -213,7 +218,8 @@ function AgentPanelBody({ const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(null); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - const isScreenFocused = useIsFocused(); + const clearOnAgentBlurRef = useRef<() => void>(() => {}); + const wasPaneFocusedRef = useRef(isPaneFocused); const reconnectToastArmedRef = useRef(false); const initAttemptTokenRef = useRef(0); const routeBottomAnchorRequestRef = useRef<{ @@ -314,21 +320,17 @@ function AgentPanelBody({ return filtered; }, [agentId, allPendingPermissions]); - const setFocusedAgentId = useCallback( - (focusedAgentId: string | null) => { - useSessionStore.getState().setFocusedAgentId(serverId, focusedAgentId); - }, - [serverId] - ); - const attentionController = useAgentAttentionClear({ agentId, client, isConnected, requiresAttention: agent?.requiresAttention, attentionReason: agent?.attentionReason, - isScreenFocused, + isScreenFocused: isPaneFocused, }); + useEffect(() => { + clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; + }, [attentionController.clearOnAgentBlur]); const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ mode: "translate", @@ -383,34 +385,38 @@ function AgentPanelBody({ } }, [connectionStatus, panelToast.api]); - useFocusEffect( - useCallback(() => { - if (!agentId || !isConnected || !hasSession) { - return; - } - ensureInitializedWithSyncErrorHandling("focus"); - }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected]) - ); + useEffect(() => { + if (!isPaneFocused || !agentId || !isConnected || !hasSession) { + return; + } + ensureInitializedWithSyncErrorHandling("focus"); + }, [ + agentId, + ensureInitializedWithSyncErrorHandling, + hasSession, + isConnected, + isPaneFocused, + ]); const isArchivingCurrentAgent = Boolean( agentId && isArchivingAgent({ serverId, agentId }) ); - useFocusEffect( - useCallback(() => { - if (!agentId) { - setFocusedAgentId(null); - return; - } + useEffect(() => { + if (wasPaneFocusedRef.current && !isPaneFocused) { + clearOnAgentBlurRef.current(); + } + wasPaneFocusedRef.current = isPaneFocused; + }, [isPaneFocused]); - setFocusedAgentId(agentId); - return () => { - attentionController.clearOnAgentBlur(); - setFocusedAgentId(null); - }; - }, [agentId, attentionController, setFocusedAgentId]) - ); + useEffect(() => { + return () => { + if (wasPaneFocusedRef.current) { + clearOnAgentBlurRef.current(); + } + }; + }, []); const isInitializing = agentId ? isInitializingFromMap !== false : false; const isHistorySyncing = useMemo(() => { @@ -797,7 +803,7 @@ function AgentPanelBody({ void; onCloseCurrentTab: () => void; onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; @@ -25,6 +26,7 @@ export function buildWorkspacePaneContentModel({ tab, normalizedServerId, normalizedWorkspaceId, + isPaneFocused, onOpenTab, onCloseCurrentTab, onRetargetCurrentTab, @@ -40,6 +42,7 @@ export function buildWorkspacePaneContentModel({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: tab.tabId, + isPaneFocused, target: tab.target, openTab: onOpenTab, closeCurrentTab: onCloseCurrentTab, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d8f714daf..103abb5ec 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -873,6 +873,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 { @@ -1833,12 +1851,14 @@ function WorkspaceScreenContent({ (input: { tab: WorkspaceTabDescriptor; paneId?: string | null; + isPaneFocused: boolean; focusPaneBeforeOpen?: boolean; }) => buildWorkspacePaneContentModel({ tab: input.tab, normalizedServerId, normalizedWorkspaceId, + isPaneFocused: input.isPaneFocused, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -1885,6 +1905,7 @@ function WorkspaceScreenContent({ ? buildPaneContentModel({ tab: activeTabDescriptor, paneId: focusedPaneTabState.pane?.id ?? null, + isPaneFocused: true, focusPaneBeforeOpen: false, }) : null, @@ -2159,13 +2180,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); }} From 72ea9b7a72abfdcd8091862653947858e1db6e68 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 19:16:06 +0700 Subject: [PATCH 7/9] fix(app): replace greedy host runtime subscriptions with targeted hooks useHostRuntimeSession returned the full HostRuntimeSnapshot, causing every consumer to rerender on any internal state change (probe cycles, client generation bumps). Replace with targeted hooks that return primitives/stable references so useSyncExternalStore skips rerenders when the subscribed value hasn't actually changed. New hooks: useHostRuntimeClient, useHostRuntimeConnectionStatus, useHostRuntimeLastError, useHostRuntimeAgentDirectoryStatus, useHostRuntimeIsDirectoryLoading. Existing useHostRuntimeIsConnected already followed this pattern. useHostRuntimeSnapshot kept only for settings-screen where probe data is legitimately needed. --- packages/app/src/app/_layout.tsx | 4 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 5 +- .../app/src/components/agent-input-area.tsx | 12 ++-- .../src/components/project-picker-modal.tsx | 5 +- packages/app/src/components/terminal-pane.tsx | 5 +- .../app/src/hooks/use-agent-autocomplete.ts | 5 +- .../app/src/hooks/use-agent-commands-query.ts | 5 +- .../app/src/hooks/use-agent-form-state.ts | 5 +- packages/app/src/hooks/use-all-agents-list.ts | 12 ++-- .../app/src/hooks/use-checkout-diff-query.ts | 5 +- .../src/hooks/use-checkout-pr-status-query.ts | 5 +- .../src/hooks/use-checkout-status-query.ts | 7 ++- packages/app/src/hooks/use-open-project.ts | 4 +- .../app/src/hooks/use-project-icon-query.ts | 5 +- packages/app/src/panels/agent-panel.tsx | 23 ++++---- packages/app/src/runtime/host-runtime.ts | 58 +++++++++++++++---- .../src/screens/agent/draft-agent-screen.tsx | 7 +-- packages/app/src/screens/settings-screen.tsx | 12 ++-- .../workspace/workspace-draft-agent-tab.tsx | 5 +- .../screens/workspace/workspace-screen.tsx | 5 +- 20 files changed, 124 insertions(+), 70 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index ddd192c8d..b17164fcb 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -23,7 +23,7 @@ import { getHostRuntimeStore, useHosts, useHostMutations, - useHostRuntimeSession, + useHostRuntimeClient, } from "@/runtime/host-runtime"; import { SessionProvider } from "@/contexts/session-context"; import type { HostProfile } from "@/types/host-connection"; @@ -176,7 +176,7 @@ function PushNotificationRouter() { } function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { - const { client } = useHostRuntimeSession(daemon.serverId); + const client = useHostRuntimeClient(daemon.serverId); if (!client) { return null; diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 4b7fb7401..54ef75896 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildHostRootRoute, buildHostWorkspaceAgentRoute, @@ -16,7 +16,8 @@ export default function HostAgentReadyRoute() { const redirectedRef = useRef(false); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const agentId = typeof params.agentId === "string" ? params.agentId : ""; - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const agentCwd = useSessionStore((state) => { if (!serverId || !agentId) { return null; diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 7ccc059c9..b0c922ec4 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -28,7 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Shortcut } from '@/components/ui/shortcut' import { Autocomplete } from '@/components/ui/autocomplete' import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' -import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { deleteAttachments, persistAttachmentFromBlob, @@ -100,14 +100,16 @@ export function AgentInputArea({ const insets = useSafeAreaInsets() const isScreenFocused = useIsFocused() - const { client, isConnected, snapshot } = useHostRuntimeSession(serverId) + const client = useHostRuntimeClient(serverId) + const isConnected = useHostRuntimeIsConnected(serverId) + const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId) const toast = useToast() const voice = useVoiceOptional() const isDictationReady = isConnected && - (snapshot?.agentDirectoryStatus === 'ready' || - snapshot?.agentDirectoryStatus === 'revalidating' || - snapshot?.agentDirectoryStatus === 'error_after_ready') + (agentDirectoryStatus === 'ready' || + agentDirectoryStatus === 'revalidating' || + agentDirectoryStatus === 'error_after_ready') const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index a1547ae43..c9ee903b3 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -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 ); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index f106b17e5..ada1657a8 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -19,7 +19,7 @@ import Svg, { import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { hasPendingTerminalModifiers, @@ -150,7 +150,8 @@ export function TerminalPane({ }); const queryClient = useQueryClient(); - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index eac27074c..491e0fe1a 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -5,7 +5,7 @@ import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-comm import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils' import { useAutocomplete } from './use-autocomplete' import { useSessionStore } from '@/stores/session-store' -import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { applyFileMentionReplacement, findActiveFileMention, @@ -138,7 +138,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut return agentCwd.trim() }, [agentCwd, isDraftContext, queryDraftConfig]) - const { client, isConnected } = useHostRuntimeSession(serverId) + const client = useHostRuntimeClient(serverId) + const isConnected = useHostRuntimeIsConnected(serverId) const mode: 'command' | 'file' | null = showFileAutocomplete ? 'file' diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index e77ab7d34..63c16e5d0 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute @@ -48,7 +48,8 @@ export function useAgentCommandsQuery({ enabled = true, draftConfig, }: UseAgentCommandsQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ queryKey: commandsQueryKey(serverId, agentId, draftConfig), diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 82c68a532..829ecfa8b 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -10,7 +10,7 @@ import type { AgentProvider, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useFormPreferences, type FormPreferences } from "./use-form-preferences"; // Explicit overrides from URL params or "New Agent" button @@ -367,7 +367,8 @@ export function useAgentFormState( }, [isVisible]); // Session state for provider model listing - const { client, isConnected } = useHostRuntimeSession(formState.serverId ?? ""); + const client = useHostRuntimeClient(formState.serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(formState.serverId ?? ""); const availableProvidersQuery = useQuery({ queryKey: ["availableProviders", formState.serverId], diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 5c4ab7dce..77cd21fee 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -3,8 +3,8 @@ import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { getHostRuntimeStore, - isHostRuntimeDirectoryLoading, - useHostRuntimeSession, + useHostRuntimeConnectionStatus, + useHostRuntimeIsDirectoryLoading, } from "@/runtime/host-runtime"; import type { AggregatedAgent, @@ -88,14 +88,14 @@ export function useAllAgentsList(options?: { const liveAgents = useSessionStore((state) => serverId ? state.sessions[serverId]?.agents ?? null : null ); - const { snapshot } = useHostRuntimeSession(serverId ?? ""); + const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? ""); const refreshAll = useCallback(() => { - if (!serverId || snapshot?.connectionStatus !== "online") { + if (!serverId || connectionStatus !== "online") { return; } void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined); - }, [runtime, serverId, snapshot?.connectionStatus]); + }, [runtime, serverId, connectionStatus]); const agents = useMemo(() => { if (!serverId || !liveAgents) { @@ -111,7 +111,7 @@ export function useAllAgentsList(options?: { }); }, [daemons, includeArchived, liveAgents, serverId]); - const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); + const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? ""); const isInitialLoad = isDirectoryLoading && agents.length === 0; const isRevalidating = isDirectoryLoading && agents.length > 0; diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index d5d0c57ee..4e088e1db 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useId, useMemo } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import { orderCheckoutDiffFiles } from "./checkout-diff-order"; @@ -56,7 +56,8 @@ export function useCheckoutDiffQuery({ enabled = true, }: UseCheckoutDiffQueryOptions) { const queryClient = useQueryClient(); - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index e4283505d..dcdfa2957 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutPrStatusResponse } from "@server/shared/messages"; const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; @@ -21,7 +21,8 @@ export function useCheckoutPrStatusQuery({ cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ queryKey: checkoutPrStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index dea647ea3..f1046c89e 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutStatusResponse } from "@server/shared/messages"; import { checkoutStatusRevalidationKey, @@ -30,7 +30,8 @@ function fetchCheckoutStatus( } export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); @@ -87,7 +88,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery * only the visible agents. */ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { - const { client } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); return useQuery({ queryKey: checkoutStatusQueryKey(serverId, cwd), diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 8cad27f3b..2582e0552 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,7 +1,7 @@ import { router } from "expo-router"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore, @@ -13,7 +13,7 @@ export function useOpenProject( ): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; const toast = useToast(); - const { client } = useHostRuntimeSession(normalizedServerId); + const client = useHostRuntimeClient(normalizedServerId); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const setHasHydratedWorkspaces = useSessionStore( (state) => state.setHasHydratedWorkspaces diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts index f34d98e42..6a5b5bbb9 100644 --- a/packages/app/src/hooks/use-project-icon-query.ts +++ b/packages/app/src/hooks/use-project-icon-query.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { ProjectIcon } from "@server/shared/messages"; export function projectIconQueryKey(serverId: string, cwd: string) { @@ -12,7 +12,8 @@ interface UseProjectIconQueryOptions { } export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) { - const { client, isConnected } = useHostRuntimeSession(serverId); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); const query = useQuery({ queryKey: projectIconQueryKey(serverId, cwd), diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2c4f545be..a2c17e684 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -23,7 +23,10 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { - useHostRuntimeSession, + useHostRuntimeClient, + useHostRuntimeConnectionStatus, + useHostRuntimeIsConnected, + useHostRuntimeLastError, useHosts, type HostRuntimeConnectionStatus, } from "@/runtime/host-runtime"; @@ -154,11 +157,10 @@ function AgentPanelContent({ const resolvedServerId = serverId.trim() || undefined; const daemons = useHosts(); const runtimeServerId = resolvedServerId ?? ""; - const { - snapshot: runtimeSnapshot, - client: runtimeClient, - isConnected: runtimeIsConnected, - } = useHostRuntimeSession(runtimeServerId); + const runtimeClient = useHostRuntimeClient(runtimeServerId); + const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); + const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); + const runtimeLastError = useHostRuntimeLastError(runtimeServerId); const connectionServerId = resolvedServerId ?? null; const daemon = connectionServerId @@ -168,9 +170,10 @@ function AgentPanelContent({ daemon?.label ?? connectionServerId ?? "Selected host"; const isUnknownDaemon = Boolean(connectionServerId && !daemon); const connectionStatus: HostRuntimeConnectionStatus = - runtimeSnapshot?.connectionStatus ?? - (isUnknownDaemon ? "offline" : "connecting"); - const lastConnectionError = runtimeSnapshot?.lastError ?? null; + isUnknownDaemon && runtimeConnectionStatus === "connecting" + ? "offline" + : runtimeConnectionStatus; + const lastConnectionError = runtimeLastError; if (!resolvedServerId || !runtimeClient) { return ( @@ -208,7 +211,7 @@ function AgentPanelBody({ serverId: string; agentId?: string; isPaneFocused: boolean; - client: NonNullable["client"]>; + client: NonNullable>; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (input: { filePath: string }) => void; diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fb31126f7..7db45073d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1755,17 +1755,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 { @@ -1777,6 +1773,46 @@ export function useHostRuntimeIsConnected(serverId: string): boolean { ); } +export function useHostRuntimeConnectionStatus( + serverId: string +): HostRuntimeConnectionStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting" + ); +} + +export function useHostRuntimeLastError(serverId: string): string | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.lastError ?? null, + () => store.getSnapshot(serverId)?.lastError ?? null + ); +} + +export function useHostRuntimeAgentDirectoryStatus( + serverId: string +): HostRuntimeAgentDirectoryStatus { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle", + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle" + ); +} + +export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribe(serverId, onStoreChange), + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)), + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)) + ); +} + export function useHosts(): HostProfile[] { const store = getHostRuntimeStore(); return useSyncExternalStore( diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 01e042361..cc902d188 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -33,7 +33,7 @@ import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-sugg import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' import { useSessionStore } from '@/stores/session-store' import { generateDraftId } from '@/stores/draft-keys' -import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime' +import { getHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store' import { MAX_CONTENT_WIDTH } from '@/constants/layout' @@ -317,9 +317,8 @@ function DraftAgentScreenContent({ return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]) }, [allAgents, sessionAgents]) - const { client: runtimeClient, isConnected: isHostOnline } = useHostRuntimeSession( - selectedServerId ?? '' - ) + const runtimeClient = useHostRuntimeClient(selectedServerId ?? '') + const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? '') const sessionClient = runtimeClient const trimmedWorkingDir = workingDir.trim() const shouldInspectRepo = trimmedWorkingDir.length > 0 diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9d04fdd9b..2d9d1a9ef 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -24,7 +24,9 @@ import { useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore, isHostRuntimeConnected, - useHostRuntimeSession, + useHostRuntimeClient, + useHostRuntimeIsConnected, + useHostRuntimeSnapshot, } from "@/runtime/host-runtime"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; @@ -992,9 +994,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); @@ -1389,7 +1391,7 @@ function DaemonCard({ onOpenSettings, }: DaemonCardProps) { const { theme } = useUnistyles(); - const { snapshot } = useHostRuntimeSession(daemon.serverId); + const snapshot = useHostRuntimeSnapshot(daemon.serverId); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; const lastError = snapshot?.lastError ?? null; diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 721417b99..3669d68e3 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -7,7 +7,7 @@ import { AgentStreamView } from '@/components/agent-stream-view' import type { ImageAttachment } from '@/components/message-input' import { useAgentFormState } from '@/hooks/use-agent-form-state' import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow' -import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' import type { Agent } from '@/stores/session-store' import { encodeImages } from '@/utils/encode-images' import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types' @@ -40,7 +40,8 @@ export function WorkspaceDraftAgentTab({ onCreated, onOpenWorkspaceFile, }: WorkspaceDraftAgentTabProps) { - const { client, isConnected } = useHostRuntimeSession(serverId) + const client = useHostRuntimeClient(serverId) + const isConnected = useHostRuntimeIsConnected(serverId) const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null) const { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 103abb5ec..a61eb9a5c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -70,7 +70,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, @@ -579,7 +579,8 @@ 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 From 48924626df9b8831eaf409b32418834a21e6c973 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 21:07:51 +0700 Subject: [PATCH 8/9] fix(app): reduce workspace screen re-renders and fix archived agent handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace greedy agents Map subscription with a Zustand selector that derives workspace agent visibility (ID sets only) with custom equality, so the workspace screen only re-renders when agents are added, removed, or archived — not on every status/activity update. Fix tab pruning to check against all known agents (including archived) instead of only active agents, so archived agent tabs survive reconciliation. Remove the auto-unarchive effect that called refreshAgent() as a workaround. Hide input area for archived agents. --- packages/app/src/panels/agent-panel.tsx | 2 +- .../workspace-agent-visibility.test.ts | 96 ++++++++----------- .../workspace/workspace-agent-visibility.ts | 74 +++++++------- .../screens/workspace/workspace-screen.tsx | 75 ++++----------- 4 files changed, 95 insertions(+), 152 deletions(-) diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a2c17e684..c1455c5c8 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -802,7 +802,7 @@ function AgentPanelBody({ - {agentId && !isArchivingCurrentAgent ? ( + {agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? ( { - it("keeps archived agents hidden from visible list but present in workspace lookup", () => { + it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { const workspaceId = "/repo/worktree"; const visible = makeAgent({ id: "visible-agent", @@ -82,87 +83,46 @@ describe("workspace agent visibility", () => { workspaceId, }); - expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["visible-agent"]); - expect(result.lookupById.has("visible-agent")).toBe(true); - expect(result.lookupById.has("archived-agent")).toBe(true); - expect(result.lookupById.has("other-workspace-agent")).toBe(false); + expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); + expect(result.knownAgentIds.has("visible-agent")).toBe(true); + expect(result.knownAgentIds.has("archived-agent")).toBe(true); + expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); it("allows explicit route open for archived agent once agents are hydrated", () => { - const archivedAgent = makeAgent({ - id: "archived-agent", - cwd: "/repo/worktree", - archivedAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const lookup = new Map([[archivedAgent.id, archivedAgent]]); + const knownAgentIds = new Set(["archived-agent"]); expect( canOpenAgentTabFromRoute({ agentId: "archived-agent", agentsHydrated: true, - workspaceAgentLookup: lookup, + knownAgentIds, }) ).toBe(true); }); - it("does not prune archived agent tabs when the workspace lookup still contains the agent", () => { - const archivedAgent = makeAgent({ - id: "archived-agent", - cwd: "/repo/worktree", - archivedAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const lookup = new Map([[archivedAgent.id, archivedAgent]]); + it("does not prune archived agent tabs when knownAgentIds contains the agent", () => { + const knownAgentIds = new Set(["archived-agent"]); expect( shouldPruneWorkspaceAgentTab({ agentId: "archived-agent", agentsHydrated: true, - workspaceAgentLookup: lookup, + knownAgentIds, }) ).toBe(false); }); - it("prunes agent tabs once agents are hydrated and the agent is missing from the workspace lookup", () => { + it("prunes agent tabs once agents are hydrated and the agent is missing from knownAgentIds", () => { expect( shouldPruneWorkspaceAgentTab({ agentId: "missing-agent", agentsHydrated: true, - workspaceAgentLookup: new Map(), + knownAgentIds: new Set(), }) ).toBe(true); }); - it("sorts same-createdAt agents by lastActivityAt descending", () => { - const workspaceId = "/repo/worktree"; - const createdAt = new Date("2026-03-04T00:00:00.000Z"); - const newerActivity = makeAgent({ - id: "newer-activity", - cwd: workspaceId, - createdAt, - lastActivityAt: new Date("2026-03-04T00:05:00.000Z"), - }); - const olderActivity = makeAgent({ - id: "older-activity", - cwd: workspaceId, - createdAt, - lastActivityAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const sessionAgents = new Map([ - [olderActivity.id, olderActivity], - [newerActivity.id, newerActivity], - ]); - - const result = deriveWorkspaceAgentVisibility({ - sessionAgents, - workspaceId, - }); - - expect(result.visibleAgents.map((agent) => agent.id)).toEqual([ - "newer-activity", - "older-activity", - ]); - }); - it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => { const sessionAgents = new Map([ [ @@ -179,7 +139,33 @@ describe("workspace agent visibility", () => { workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid", }); - expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["slash-agent"]); - expect(result.lookupById.has("slash-agent")).toBe(true); + expect(result.activeAgentIds).toEqual(new Set(["slash-agent"])); + expect(result.knownAgentIds.has("slash-agent")).toBe(true); + }); + + describe("workspaceAgentVisibilityEqual", () => { + it("returns true for identical sets", () => { + const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + const b = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); + }); + + it("returns false when activeAgentIds differ", () => { + const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; + const b = { activeAgentIds: new Set(["b"]), knownAgentIds: new Set(["a"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns false when knownAgentIds differ", () => { + const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; + const b = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a", "b"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns true for empty sets", () => { + const a = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + const b = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); + }); }); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index db79bd9f9..5d90bf7f9 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -1,68 +1,66 @@ import type { Agent } from "@/stores/session-store"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; -function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { - const sorted = [...agents]; - sorted.sort((left, right) => { - const createdAtDelta = right.createdAt.getTime() - left.createdAt.getTime(); - if (createdAtDelta !== 0) { - return createdAtDelta; - } - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); - }); - return sorted; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (!value) { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - function normalizeWorkspaceId(value: string | null | undefined): string { return normalizeWorkspaceIdentity(value) ?? ""; } +export interface WorkspaceAgentVisibility { + activeAgentIds: Set; + knownAgentIds: Set; +} + export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; workspaceId: string; -}): { - visibleAgents: Agent[]; - lookupById: Map; -} { +}): WorkspaceAgentVisibility { const { sessionAgents, workspaceId } = input; const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId); if (!sessionAgents || !workspaceId) { return { - visibleAgents: [], - lookupById: new Map(), + activeAgentIds: new Set(), + knownAgentIds: new Set(), }; } - const lookupById = new Map(); - const visible: Agent[] = []; + const activeAgentIds = new Set(); + const knownAgentIds = new Set(); for (const agent of sessionAgents.values()) { if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) { continue; } - lookupById.set(agent.id, agent); + knownAgentIds.add(agent.id); if (!agent.archivedAt) { - visible.push(agent); + activeAgentIds.add(agent.id); } } - return { - visibleAgents: sortAgentsByCreatedAtDescending(visible), - lookupById, - }; + return { activeAgentIds, knownAgentIds }; +} + +export function workspaceAgentVisibilityEqual( + a: WorkspaceAgentVisibility, + b: WorkspaceAgentVisibility, +): boolean { + return setsEqual(a.activeAgentIds, b.activeAgentIds) && setsEqual(a.knownAgentIds, b.knownAgentIds); +} + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) { + return false; + } + for (const item of a) { + if (!b.has(item)) { + return false; + } + } + return true; } export function canOpenAgentTabFromRoute(input: { agentId: string; agentsHydrated: boolean; - workspaceAgentLookup: Map; + knownAgentIds: Set; }): boolean { if (!input.agentId.trim()) { return false; @@ -70,13 +68,13 @@ export function canOpenAgentTabFromRoute(input: { if (!input.agentsHydrated) { return true; } - return input.workspaceAgentLookup.has(input.agentId); + return input.knownAgentIds.has(input.agentId); } export function shouldPruneWorkspaceAgentTab(input: { agentId: string; agentsHydrated: boolean; - workspaceAgentLookup: Map; + knownAgentIds: Set; }): boolean { if (!input.agentId.trim()) { return false; @@ -84,5 +82,5 @@ export function shouldPruneWorkspaceAgentTab(input: { if (!input.agentsHydrated) { return false; } - return !input.workspaceAgentLookup.has(input.agentId); + return !input.knownAgentIds.has(input.agentId); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a61eb9a5c..fbecfc0f7 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, @@ -96,6 +97,7 @@ import { import { deriveWorkspaceAgentVisibility, shouldPruneWorkspaceAgentTab, + workspaceAgentVisibilityEqual, } from "@/screens/workspace/workspace-agent-visibility"; import { deriveWorkspacePaneState, @@ -582,18 +584,15 @@ function WorkspaceScreenContent({ 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, @@ -823,8 +822,6 @@ function WorkspaceScreenContent({ return () => handler.remove(); }, [closeToAgent, isExplorerOpen]); - const agentsById = workspaceAgentVisibility.lookupById; - const persistenceKey = useMemo( () => buildWorkspaceTabPersistenceKey({ @@ -853,7 +850,6 @@ function WorkspaceScreenContent({ const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const consumedOpenIntentsRef = useRef(new Set()); - const reopenedArchivedOpenIntentsRef = useRef(new Set()); const pendingCloseTabIdsRef = useRef(new Set()); const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState(null); const currentOpenIntentKey = useMemo( @@ -957,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; @@ -1075,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 && @@ -1089,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 }); @@ -1104,7 +1063,7 @@ function WorkspaceScreenContent({ shouldPruneWorkspaceAgentTab({ agentId: tab.target.agentId, agentsHydrated: hasHydratedAgents, - workspaceAgentLookup: agentsById, + knownAgentIds: workspaceAgentVisibility.knownAgentIds, }) ) { closeWorkspaceTab(persistenceKey, tab.tabId); @@ -1126,7 +1085,7 @@ function WorkspaceScreenContent({ terminals, terminalsQuery.isSuccess, uiTabs, - workspaceAgents, + workspaceAgentVisibility, ]); const activeTabId = resolvedPaneTabState.activeTabId; @@ -1180,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; } @@ -1202,7 +1161,7 @@ function WorkspaceScreenContent({ persistenceKey, terminals.length, tabs.length, - workspaceAgents.length, + workspaceAgentVisibility.activeAgentIds.size, ]); const handleOpenFileFromExplorer = useCallback( @@ -1485,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) { @@ -1510,7 +1469,7 @@ function WorkspaceScreenContent({ toast.error("Copy failed"); } }, - [sessionAgents, toast] + [normalizedServerId, toast] ); const handleCopyWorkspacePath = useCallback(async () => { From 862cc43db7a745c1d39125e641b7f6e28933a737 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Mar 2026 00:12:44 +0700 Subject: [PATCH 9/9] feat: stream persisted history and show workspace kind indicators --- .../src/components/sidebar-workspace-list.tsx | 27 ++- .../agent/providers/claude-agent.test.ts | 39 ++++ .../server/agent/providers/claude-agent.ts | 186 ++++++++++++++---- ...ude-agent.voice-history-regression.test.ts | 150 +++++++++++++- .../claude/task-notification-tool-call.ts | 56 +++++- packages/server/src/utils/checkout-git.ts | 48 +++-- 6 files changed, 440 insertions(+), 66 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5757d26ab..5acb8868c 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -25,7 +25,7 @@ import { navigateToWorkspace } from '@/hooks/use-workspace-navigation' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' import * as Clipboard from 'expo-clipboard' -import { Archive, ChevronDown, ChevronRight, Copy, MoreVertical, Plus } from 'lucide-react-native' +import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, Monitor, MoreVertical, Plus } from 'lucide-react-native' import { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' @@ -160,18 +160,26 @@ function resolveStatusDotColor(input: { function WorkspaceStatusIndicator({ bucket, + workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry['statusBucket'] + workspaceKind: SidebarWorkspaceEntry['workspaceKind'] loading?: boolean }) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) - const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done' + const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done' - if (shouldRenderIdlePlaceholder) { - return null + if (isIdle) { + const KindIcon = workspaceKind === 'local_checkout' ? Monitor : workspaceKind === 'worktree' ? FolderGit2 : null + if (!KindIcon) return null + return ( + + + + ) } return ( @@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({ ) : shouldShowSyncedLoader ? ( + ) : bucket === 'needs_input' ? ( + + ) : bucket === 'attention' ? ( + ) : ( )} @@ -213,7 +225,7 @@ function ProjectLeadingVisual({ {showChevron && chevron !== null ? ( ) : shouldShowWorkspaceStatus && activeWorkspace ? ( - + ) : iconDataUri ? ( ) : ( @@ -686,6 +698,7 @@ function WorkspaceRowInner({ > ({ workspaceRowLeft: { flexDirection: 'row', alignItems: 'center', - gap: theme.spacing[1], + gap: theme.spacing[2], flex: 1, minWidth: 0, }, @@ -1938,7 +1951,7 @@ const styles = StyleSheet.create((theme) => ({ position: 'relative', }, workspaceStatusDot: { - width: 11, + width: 14, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index d35e0d80a..7be627ea5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -147,6 +147,45 @@ describe("convertClaudeHistoryEntry", () => { ]); }); + test("maps queue-operation task notifications to synthetic tool calls", () => { + const entry = { + type: "queue-operation", + operation: "enqueue", + uuid: "task-note-queue-1", + content: [ + "", + "bg-queue-1", + "completed", + "Background task completed", + "/tmp/bg-queue-1.txt", + "", + ].join("\n"), + }; + + expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + { + type: "tool_call", + callId: "task_notification_task-note-queue-1", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "wrench", + text: entry.content, + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-queue-1", + status: "completed", + outputFile: "/tmp/bg-queue-1.txt", + }, + }, + ]); + }); + test("passes assistant content blocks through to the mapper", () => { const entry = { type: "assistant", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 4fd2d06d8..fcd948ee0 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -29,6 +29,7 @@ import { mapClaudeRunningToolCall, } from "./claude/tool-call-mapper.js"; import { + coerceTaskNotificationHistoryRecordToSystemMessage, isTaskNotificationUserContent, mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, @@ -1495,6 +1496,9 @@ class ClaudeAgentSession implements AgentSession { private readonly timelineAssembler = new TimelineAssembler(); private persistedHistory: AgentTimelineItem[] = []; private historyPending = false; + private historyOffsetSessionId: string | null = null; + private historyReadOffsetBytes = 0; + private historyLineFragment = ""; private turnState: TurnState = "idle"; private preReplayMetadataSeen = false; private pendingAutonomousWakeReservations = 0; @@ -3074,6 +3078,9 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; this.persistedHistory = []; this.historyPending = false; + this.historyOffsetSessionId = null; + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; this.cachedRuntimeInfo = null; this.queryRestartNeeded = false; return true; @@ -3998,46 +4005,163 @@ class ClaudeAgentSession implements AgentSession { } } - private waitForLiveHistoryPoll(): Promise { - return new Promise((resolve) => setTimeout(resolve, 250)); + private async waitForLiveHistoryPoll(): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)); + if (this.claudeSessionId) { + this.loadPersistedHistory(this.claudeSessionId, { dispatchLive: true }); + } } - private loadPersistedHistory(sessionId: string) { + private loadPersistedHistory( + sessionId: string, + options?: { dispatchLive?: boolean } + ) { try { const historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { return; } - const content = fs.readFileSync(historyPath, "utf8"); - const timeline: AgentTimelineItem[] = []; - for (const line of content.split(/\n+/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed); - if (entry.isSidechain) { - continue; - } - if (entry.type === "user" && typeof entry.uuid === "string") { - this.rememberUserMessageId(entry.uuid); - } - const items = this.convertHistoryEntry(entry); - if (items.length > 0) { - timeline.push(...items); - } - } catch (error) { - // ignore malformed history line - } + if (this.historyOffsetSessionId !== sessionId) { + this.historyOffsetSessionId = sessionId; + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; } - if (timeline.length > 0) { - this.persistedHistory = timeline; - this.historyPending = true; + const content = fs.readFileSync(historyPath); + if (content.byteLength < this.historyReadOffsetBytes) { + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; } + if (content.byteLength === this.historyReadOffsetBytes) { + return; + } + + const unreadChunk = content + .subarray(this.historyReadOffsetBytes) + .toString("utf8"); + this.historyReadOffsetBytes = content.byteLength; + this.ingestPersistedHistoryChunk(unreadChunk, { + dispatchLive: options?.dispatchLive ?? false, + }); } catch (error) { // ignore history load failures } } + private ingestPersistedHistoryChunk( + chunk: string, + options: { dispatchLive: boolean } + ): void { + if (!chunk) { + return; + } + + const combined = `${this.historyLineFragment}${chunk}`; + this.historyLineFragment = ""; + const lines = combined.split(/\r?\n/); + const trailing = lines.pop() ?? ""; + const timeline: AgentTimelineItem[] = []; + + for (const line of lines) { + this.ingestPersistedHistoryLine(line, { + dispatchLive: options.dispatchLive, + timeline, + }); + } + + if (trailing.trim().length > 0) { + const handled = this.ingestPersistedHistoryLine(trailing, { + dispatchLive: options.dispatchLive, + timeline, + }); + if (!handled) { + this.historyLineFragment = trailing; + } + } + + if (!options.dispatchLive && timeline.length > 0) { + this.persistedHistory = [...this.persistedHistory, ...timeline]; + this.historyPending = true; + } + } + + private ingestPersistedHistoryLine( + line: string, + options: { + dispatchLive: boolean; + timeline: AgentTimelineItem[]; + } + ): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return true; + } + + let entry: Record; + try { + entry = JSON.parse(trimmed) as Record; + } catch { + return false; + } + + if (entry.isSidechain) { + return true; + } + if (entry.type === "user" && typeof entry.uuid === "string") { + this.rememberUserMessageId(entry.uuid); + } + + if (options.dispatchLive) { + this.dispatchPersistedHistoryEntry(entry); + return true; + } + + const items = this.convertHistoryEntry(entry); + if (items.length > 0) { + options.timeline.push(...items); + } + return true; + } + + private dispatchPersistedHistoryEntry(entry: Record): void { + const liveMessage = this.normalizePersistedHistoryEntryToLiveMessage(entry); + if (liveMessage) { + this.routeSdkMessageFromPump(liveMessage); + return; + } + + const items = this.convertHistoryEntry(entry); + for (const item of items) { + this.pushEvent({ + type: "timeline", + item, + provider: "claude", + }); + } + } + + private normalizePersistedHistoryEntryToLiveMessage( + entry: Record + ): SDKMessage | null { + const taskNotificationMessage = + coerceTaskNotificationHistoryRecordToSystemMessage(entry); + if (taskNotificationMessage) { + return taskNotificationMessage as unknown as SDKMessage; + } + + const type = readTrimmedString(entry.type); + switch (type) { + case "assistant": + case "result": + case "stream_event": + case "system": + case "tool_progress": + case "user": + return entry as unknown as SDKMessage; + default: + return null; + } + } + private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; @@ -4624,13 +4748,9 @@ export function convertClaudeHistoryEntry( }]; } - if (entry.type === "system") { - const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall( - entry - ); - if (taskNotificationItem) { - return [taskNotificationItem]; - } + const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry); + if (taskNotificationItem) { + return [taskNotificationItem]; } if (entry.isCompactSummary) { diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index 1a1e25912..a983f7605 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -22,6 +22,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed"; +const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER"; function buildSdkQueryMock() { const events = [ @@ -70,6 +72,19 @@ function buildSdkQueryMock() { }; } +function buildIdleSdkQueryMock() { + return { + next: vi.fn(async () => ({ done: true, value: undefined })), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + }; +} + function collectTimelineText(events: AgentStreamEvent[]): string { const chunks: string[] = []; for (const event of events) { @@ -86,6 +101,28 @@ function collectTimelineText(events: AgentStreamEvent[]): string { return chunks.join("\n"); } +async function readNextEvent( + iterator: AsyncIterator, + timeoutMs: number +): Promise { + const outcome = await Promise.race([ + iterator.next().then((result) => ({ kind: "result" as const, result })), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); + }), + ]); + + if (outcome.kind === "timeout") { + throw new Error("Timed out waiting for live event"); + } + + if (outcome.result.done) { + throw new Error("Live event stream ended before appended transcript arrived"); + } + + return outcome.result.value; +} + describe("ClaudeAgentSession history replay regression", () => { let tempRoot: string; let cwd: string; @@ -215,6 +252,117 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + test("emits appended transcript lines through streamLiveEvents after history was primed", async () => { + sdkMocks.query.mockImplementation(() => { + const mock = buildIdleSdkQueryMock(); + sdkMocks.lastQuery = mock; + return mock; + }); + + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const historyPath = path.join( + configDir, + "projects", + sanitized, + "history-session.jsonl" + ); + + const session = await client.resumeSession(handle, { cwd }); + + try { + for await (const _event of session.streamHistory()) { + // Prime existing persisted history the same way agent-manager does. + } + + const liveEvents = session.streamLiveEvents(); + const iterator = liveEvents[Symbol.asyncIterator](); + + appendFileSync( + historyPath, + `\n${JSON.stringify({ + type: "queue-operation", + operation: "enqueue", + uuid: "appended-task-note-1", + content: [ + "", + "appended-bg-1", + "completed", + `${APPENDED_TASK_NOTIFICATION_MARKER}`, + "/tmp/appended-bg-1.txt", + "", + ].join("\n"), + })}\n${JSON.stringify({ + type: "assistant", + sessionId: "history-session", + cwd, + message: { + role: "assistant", + content: APPENDED_ASSISTANT_MARKER, + }, + })}`, + "utf8" + ); + + const appendedEvents: AgentStreamEvent[] = []; + for (let attempt = 0; attempt < 4; attempt += 1) { + appendedEvents.push(await readNextEvent(iterator, 1_500)); + const sawTaskNotification = appendedEvents.some( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ); + const sawAssistant = appendedEvents.some( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes(APPENDED_ASSISTANT_MARKER) + ); + if (sawTaskNotification && sawAssistant) { + break; + } + } + const timelineText = collectTimelineText(appendedEvents); + const taskNotificationEvent = appendedEvents.find( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ); + const turnStartedEvent = appendedEvents.find( + (event): event is Extract => + event.type === "turn_started" + ); + + expect(taskNotificationEvent).toBeTruthy(); + expect(turnStartedEvent).toBeTruthy(); + expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER); + expect(taskNotificationEvent?.item.metadata).toMatchObject({ + taskId: "appended-bg-1", + status: "completed", + outputFile: "/tmp/appended-bg-1.txt", + }); + expect(taskNotificationEvent?.item.detail).toMatchObject({ + type: "plain_text", + label: APPENDED_TASK_NOTIFICATION_MARKER, + }); + } finally { + await session.close(); + } + }); + test("listCommands includes rewind command", async () => { const logger = createTestLogger(); const client = new ClaudeAgentClient({ logger }); diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index 17bcc9f9e..1017da89e 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -32,9 +32,10 @@ const TaskNotificationUserContentSchema = z.union([ z.array(TaskNotificationUserContentBlockSchema), ]); -const TaskNotificationSystemRecordSchema = z +const TaskNotificationHistoryRecordSchema = z .object({ - subtype: z.literal("task_notification"), + type: z.string().optional(), + subtype: z.string().optional(), uuid: z.string().optional(), message_id: z.string().optional(), task_id: z.string().optional(), @@ -57,13 +58,24 @@ export type MapTaskNotificationUserContentToToolCallInput = z.infer< >; type TaskNotificationUserContent = z.infer; -type TaskNotificationSystemRecord = z.infer; +type TaskNotificationHistoryRecord = z.infer; type TaskNotificationLifecycle = | { status: "completed"; error: null } | { status: "failed"; error: unknown } | { status: "canceled"; error: null }; +export type TaskNotificationSystemMessageLike = { + type: "system"; + subtype: "task_notification"; + uuid?: string; + task_id?: string; + status?: "completed" | "failed" | "stopped"; + summary?: string; + output_file?: string; + content?: string; +}; + type ReadTaskNotificationTagInput = { text: string; tagName: string; @@ -157,12 +169,18 @@ function parseTaskNotificationFromUserContent( function parseTaskNotificationFromSystemRecord( record: unknown ): TaskNotificationEnvelope | null { - const parsedRecord = TaskNotificationSystemRecordSchema.safeParse(record); + const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record); if (!parsedRecord.success) { return null; } - const systemRecord: TaskNotificationSystemRecord = parsedRecord.data; + const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data; + const isSystemTaskNotification = + systemRecord.type === "system" && systemRecord.subtype === "task_notification"; + const isQueueOperation = systemRecord.type === "queue-operation"; + if (!isSystemTaskNotification && !isQueueOperation) { + return null; + } const rawText = toNonEmptyString(systemRecord.content); return TaskNotificationEnvelopeSchema.parse({ @@ -330,3 +348,31 @@ export function mapTaskNotificationSystemRecordToToolCall( } return toTaskNotificationToolCall(parsed); } + +export function coerceTaskNotificationHistoryRecordToSystemMessage( + record: unknown +): TaskNotificationSystemMessageLike | null { + const parsed = parseTaskNotificationFromSystemRecord(record); + if (!parsed) { + return null; + } + + const normalizedStatus = parsed.status?.toLowerCase() ?? null; + const status = + normalizedStatus === "failed" || normalizedStatus === "error" + ? "failed" + : normalizedStatus === "canceled" || normalizedStatus === "cancelled" + ? "stopped" + : "completed"; + + return { + type: "system", + subtype: "task_notification", + ...(parsed.messageId ? { uuid: parsed.messageId } : {}), + ...(parsed.taskId ? { task_id: parsed.taskId } : {}), + status, + ...(parsed.summary ? { summary: parsed.summary } : {}), + ...(parsed.outputFile ? { output_file: parsed.outputFile } : {}), + ...(parsed.rawText ? { content: parsed.rawText } : {}), + }; +} diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7cf9e83d2..e2448aa7a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1109,36 +1109,44 @@ export async function getCheckoutShortstat( const configured = await getConfiguredBaseRefForCwd(cwd, context); const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); - if (!localBaseRef) { - return null; - } - const currentBranch = await getCurrentBranch(cwd); - if (currentBranch === localBaseRef) { - return null; - } - const comparisonBaseRef = await resolveBestComparisonBaseRef( - cwd, - normalizeLocalBranchRefName(localBaseRef) - ); + let diffTarget: string; - let mergeBase: string; - try { - const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, { + if (currentBranch && localBaseRef && currentBranch !== localBaseRef) { + // Feature branch: diff against the merge-base with the base branch + const comparisonBaseRef = await resolveBestComparisonBaseRef( cwd, - env: READ_ONLY_GIT_ENV, - }); - mergeBase = stdout.trim(); - if (!mergeBase) { + normalizeLocalBranchRefName(localBaseRef) + ); + + try { + const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const mergeBase = stdout.trim(); + if (!mergeBase) { + return null; + } + diffTarget = mergeBase; + } catch { return null; } - } catch { + } else if (currentBranch) { + // On the base branch (or no base ref configured): diff against remote tracking branch + const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`); + if (!hasOrigin) { + return null; + } + diffTarget = `origin/${currentBranch}`; + } else { return null; } try { - const { stdout } = await execAsync(`git diff --shortstat ${mergeBase} HEAD`, { + // Omit HEAD so the diff includes uncommitted (staged + unstaged) changes + const { stdout } = await execAsync(`git diff --shortstat ${diffTarget}`, { cwd, env: READ_ONLY_GIT_ENV, });