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