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,
|
getHostRuntimeStore,
|
||||||
useHosts,
|
useHosts,
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeSession,
|
useHostRuntimeClient,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
@@ -181,7 +181,7 @@ function PushNotificationRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||||
const { client } = useHostRuntimeSession(daemon.serverId);
|
const client = useHostRuntimeClient(daemon.serverId);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return null;
|
return null;
|
||||||
@@ -257,6 +257,9 @@ function QueryProvider({ children }: { children: ReactNode }) {
|
|||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowStyle = { flex: 1, flexDirection: "row" } as const;
|
||||||
|
const flexStyle = { flex: 1 } as const;
|
||||||
|
|
||||||
interface AppContainerProps {
|
interface AppContainerProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
@@ -270,23 +273,12 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
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 toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
|
||||||
|
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
const isOpen = chromeEnabled
|
|
||||||
? isMobile
|
|
||||||
? mobileView === "agent-list"
|
|
||||||
: desktopAgentListOpen
|
|
||||||
: false;
|
|
||||||
const openGestureEnabled =
|
|
||||||
chromeEnabled && isMobile && mobileView === "agent";
|
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
@@ -295,6 +287,50 @@ function AppContainer({
|
|||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
toggleFileExplorer,
|
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 {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -303,18 +339,14 @@ function AppContainer({
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
|
|
||||||
// Track initial touch position for manual activation
|
|
||||||
const touchStartX = useSharedValue(0);
|
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(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
const touch = event.changedTouches[0];
|
const touch = event.changedTouches[0];
|
||||||
@@ -328,13 +360,11 @@ function AppContainer({
|
|||||||
|
|
||||||
const deltaX = touch.absoluteX - touchStartX.value;
|
const deltaX = touch.absoluteX - touchStartX.value;
|
||||||
|
|
||||||
// If horizontal scroll is scrolled right, fail so ScrollView handles it
|
|
||||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate after 15px rightward movement
|
|
||||||
if (deltaX > 15) {
|
if (deltaX > 15) {
|
||||||
stateManager.activate();
|
stateManager.activate();
|
||||||
}
|
}
|
||||||
@@ -343,7 +373,6 @@ function AppContainer({
|
|||||||
isGesturing.value = true;
|
isGesturing.value = true;
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
// Start from closed position (-windowWidth) and move towards 0
|
|
||||||
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
|
||||||
translateX.value = newTranslateX;
|
translateX.value = newTranslateX;
|
||||||
backdropOpacity.value = interpolate(
|
backdropOpacity.value = interpolate(
|
||||||
@@ -355,7 +384,6 @@ function AppContainer({
|
|||||||
})
|
})
|
||||||
.onEnd((event) => {
|
.onEnd((event) => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
// Open if dragged more than 1/3 of sidebar or fast swipe
|
|
||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
@@ -375,37 +403,15 @@ function AppContainer({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
openAgentList,
|
openAgentList,
|
||||||
mobileView,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
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 (
|
return (
|
||||||
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
<GestureDetector gesture={openGesture} touchAction="pan-y">
|
||||||
{content}
|
{children}
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -435,6 +441,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||||
<HostSessionManager />
|
<HostSessionManager />
|
||||||
|
<FaviconStatusSync />
|
||||||
{children}
|
{children}
|
||||||
</VoiceProvider>
|
</VoiceProvider>
|
||||||
);
|
);
|
||||||
@@ -485,7 +492,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
const params = useGlobalSearchParams<{ open?: string | string[] }>();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
useFaviconStatus();
|
|
||||||
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const shouldShowAppChrome = activeServerId !== null;
|
const shouldShowAppChrome = activeServerId !== null;
|
||||||
|
|
||||||
@@ -525,6 +531,11 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FaviconStatusSync() {
|
||||||
|
useFaviconStatus();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function NavigationActiveWorkspaceObserver() {
|
function NavigationActiveWorkspaceObserver() {
|
||||||
const navigationRef = useNavigationContainerRef();
|
const navigationRef = useNavigationContainerRef();
|
||||||
|
|
||||||
@@ -625,10 +636,13 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
<Stack.Screen
|
||||||
|
name="h/[serverId]/workspace/[workspaceId]"
|
||||||
|
options={{ freezeOnBlur: true }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="h/[serverId]/agent/[agentId]"
|
name="h/[serverId]/agent/[agentId]"
|
||||||
options={{ gestureEnabled: false }}
|
options={{ gestureEnabled: false, freezeOnBlur: true }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/agents" />
|
<Stack.Screen name="h/[serverId]/agents" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
@@ -16,7 +16,8 @@ export default function HostAgentReadyRoute() {
|
|||||||
const redirectedRef = useRef(false);
|
const redirectedRef = useRef(false);
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
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) => {
|
const agentCwd = useSessionStore((state) => {
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { Shortcut } from '@/components/ui/shortcut'
|
import { Shortcut } from '@/components/ui/shortcut'
|
||||||
import { Autocomplete } from '@/components/ui/autocomplete'
|
import { Autocomplete } from '@/components/ui/autocomplete'
|
||||||
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
||||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
||||||
import {
|
import {
|
||||||
deleteAttachments,
|
deleteAttachments,
|
||||||
persistAttachmentFromBlob,
|
persistAttachmentFromBlob,
|
||||||
@@ -100,14 +100,16 @@ export function AgentInputArea({
|
|||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const isScreenFocused = useIsFocused()
|
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 toast = useToast()
|
||||||
const voice = useVoiceOptional()
|
const voice = useVoiceOptional()
|
||||||
const isDictationReady =
|
const isDictationReady =
|
||||||
isConnected &&
|
isConnected &&
|
||||||
(snapshot?.agentDirectoryStatus === 'ready' ||
|
(agentDirectoryStatus === 'ready' ||
|
||||||
snapshot?.agentDirectoryStatus === 'revalidating' ||
|
agentDirectoryStatus === 'revalidating' ||
|
||||||
snapshot?.agentDirectoryStatus === 'error_after_ready')
|
agentDirectoryStatus === 'error_after_ready')
|
||||||
|
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
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 { View, Pressable, Text, Platform } from 'react-native'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -18,7 +29,7 @@ import { usePanelStore } from '@/stores/panel-store'
|
|||||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
||||||
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
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 { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||||
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
|
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
import { Combobox } from '@/components/ui/combobox'
|
||||||
@@ -34,12 +45,58 @@ import {
|
|||||||
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
|
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
|
||||||
|
|
||||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||||
|
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
|
||||||
|
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
selectedAgentId?: string
|
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 { theme } = useUnistyles()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
@@ -109,10 +166,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
})),
|
})),
|
||||||
[daemons, runtime, runtimeConnectionStatusSignature]
|
[daemons, runtime, runtimeConnectionStatusSignature]
|
||||||
)
|
)
|
||||||
const hostTriggerRef = useRef<View>(null)
|
const hostTriggerRef = useRef<View | null>(null)
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
|
||||||
|
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
@@ -121,21 +177,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
})
|
})
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
|
||||||
useSidebarShortcutModel(projects)
|
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 [isManualRefresh, setIsManualRefresh] = useState(false)
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
@@ -143,17 +185,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
refreshAll()
|
refreshAll()
|
||||||
}, [refreshAll])
|
}, [refreshAll])
|
||||||
|
|
||||||
// Reset manual refresh flag when revalidation completes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRevalidating && isManualRefresh) {
|
if (!isRevalidating && isManualRefresh) {
|
||||||
setIsManualRefresh(false)
|
setIsManualRefresh(false)
|
||||||
}
|
}
|
||||||
}, [isRevalidating, isManualRefresh])
|
}, [isRevalidating, isManualRefresh])
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
closeToAgent()
|
|
||||||
}, [closeToAgent])
|
|
||||||
|
|
||||||
const openProjectPicker = useOpenProjectPicker(activeServerId)
|
const openProjectPicker = useOpenProjectPicker(activeServerId)
|
||||||
|
|
||||||
const handleOpenProjectMobile = useCallback(() => {
|
const handleOpenProjectMobile = useCallback(() => {
|
||||||
@@ -165,7 +202,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
void openProjectPicker()
|
void openProjectPicker()
|
||||||
}, [openProjectPicker])
|
}, [openProjectPicker])
|
||||||
|
|
||||||
// Mobile: close sidebar and navigate
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return
|
||||||
@@ -174,7 +210,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
router.push(buildHostSettingsRoute(activeServerId) as any)
|
||||||
}, [activeServerId, closeToAgent])
|
}, [activeServerId, closeToAgent])
|
||||||
|
|
||||||
// Desktop: just navigate, don't close
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return
|
||||||
@@ -182,17 +217,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
router.push(buildHostSettingsRoute(activeServerId) as any)
|
||||||
}, [activeServerId])
|
}, [activeServerId])
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isMobile) {
|
|
||||||
translateX.value = -windowWidth
|
|
||||||
backdropOpacity.value = 0
|
|
||||||
closeToAgent()
|
|
||||||
}
|
|
||||||
router.push(buildHostAgentsRoute(activeServerId) as any)
|
router.push(buildHostAgentsRoute(activeServerId) as any)
|
||||||
}, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
|
}, [activeServerId])
|
||||||
|
|
||||||
const handleHostSelect = useCallback(
|
const handleHostSelect = useCallback(
|
||||||
(nextServerId: string) => {
|
(nextServerId: string) => {
|
||||||
@@ -206,77 +236,201 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
[pathname]
|
[pathname]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Close gesture (swipe left to close when sidebar is open)
|
const sharedProps = {
|
||||||
const closeGesture = Gesture.Pan()
|
theme,
|
||||||
.withRef(closeGestureRef)
|
activeServerId,
|
||||||
.enabled(isOpen)
|
activeHostLabel,
|
||||||
// Use manual activation so child views keep touch streams unless we detect
|
activeHostStatusColor,
|
||||||
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
|
hostOptions,
|
||||||
.manualActivation(true)
|
hostTriggerRef,
|
||||||
.onTouchesDown((event) => {
|
isHostPickerOpen,
|
||||||
const touch = event.changedTouches[0]
|
setIsHostPickerOpen,
|
||||||
if (!touch) {
|
projects,
|
||||||
return
|
isInitialLoad,
|
||||||
}
|
isRevalidating,
|
||||||
closeTouchStartX.value = touch.absoluteX
|
isManualRefresh,
|
||||||
closeTouchStartY.value = touch.absoluteY
|
collapsedProjectKeys,
|
||||||
})
|
shortcutIndexByWorkspaceKey,
|
||||||
.onTouchesMove((event, stateManager) => {
|
toggleProjectCollapsed,
|
||||||
const touch = event.changedTouches[0]
|
setProjectCollapsed,
|
||||||
if (!touch || event.numberOfTouches !== 1) {
|
handleRefresh,
|
||||||
stateManager.fail()
|
handleHostSelect,
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = touch.absoluteX - closeTouchStartX.value
|
if (isMobile) {
|
||||||
const deltaY = touch.absoluteY - closeTouchStartY.value
|
return (
|
||||||
const absDeltaX = Math.abs(deltaX)
|
<MobileSidebar
|
||||||
const absDeltaY = Math.abs(deltaY)
|
{...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.
|
return (
|
||||||
if (deltaX >= 10) {
|
<DesktopSidebar
|
||||||
stateManager.fail()
|
{...sharedProps}
|
||||||
return
|
isOpen={isOpen}
|
||||||
}
|
handleOpenProject={handleOpenProjectDesktop}
|
||||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
handleSettings={handleSettingsDesktop}
|
||||||
stateManager.fail()
|
handleViewMore={handleViewMoreNavigate}
|
||||||
return
|
/>
|
||||||
}
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// Activate only on intentional leftward movement.
|
function MobileSidebar({
|
||||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
theme,
|
||||||
stateManager.activate()
|
activeServerId,
|
||||||
}
|
activeHostLabel,
|
||||||
})
|
activeHostStatusColor,
|
||||||
.onStart(() => {
|
hostOptions,
|
||||||
isGesturing.value = true
|
hostTriggerRef,
|
||||||
})
|
isHostPickerOpen,
|
||||||
.onUpdate((event) => {
|
setIsHostPickerOpen,
|
||||||
if (!isMobile) return
|
projects,
|
||||||
// Only allow swiping left (closing)
|
isInitialLoad,
|
||||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
|
isRevalidating,
|
||||||
translateX.value = newTranslateX
|
isManualRefresh,
|
||||||
backdropOpacity.value = interpolate(
|
collapsedProjectKeys,
|
||||||
newTranslateX,
|
shortcutIndexByWorkspaceKey,
|
||||||
[-windowWidth, 0],
|
toggleProjectCollapsed,
|
||||||
[0, 1],
|
setProjectCollapsed,
|
||||||
Extrapolation.CLAMP
|
handleRefresh,
|
||||||
)
|
handleHostSelect,
|
||||||
})
|
handleOpenProject,
|
||||||
.onEnd((event) => {
|
handleSettings,
|
||||||
isGesturing.value = false
|
insetsTop,
|
||||||
if (!isMobile) return
|
insetsBottom,
|
||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
isOpen,
|
||||||
if (shouldClose) {
|
closeToAgent,
|
||||||
animateToClose()
|
handleViewMoreNavigate,
|
||||||
runOnJS(handleClose)()
|
}: MobileSidebarProps) {
|
||||||
} else {
|
const {
|
||||||
animateToOpen()
|
translateX,
|
||||||
}
|
backdropOpacity,
|
||||||
})
|
windowWidth,
|
||||||
.onFinalize(() => {
|
animateToOpen,
|
||||||
isGesturing.value = false
|
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(() => ({
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateX: translateX.value }],
|
transform: [{ translateX: translateX.value }],
|
||||||
@@ -287,160 +441,174 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
|
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'
|
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">
|
return (
|
||||||
<Animated.View
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
style={[
|
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||||
styles.mobileSidebar,
|
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||||
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
</Animated.View>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Middle: scrollable project/workspace tree */}
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
{isInitialLoad ? (
|
<Animated.View
|
||||||
<SidebarAgentListSkeleton />
|
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
||||||
) : (
|
pointerEvents="auto"
|
||||||
<SidebarWorkspaceList
|
>
|
||||||
serverId={activeServerId}
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
<View style={styles.sidebarHeader}>
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
<View style={styles.sidebarHeaderRow}>
|
||||||
onSetProjectCollapsed={setProjectCollapsed}
|
<Pressable
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
style={styles.newAgentButton}
|
||||||
projects={projects}
|
testID="sidebar-sessions"
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
onPress={handleViewMore}
|
||||||
onRefresh={handleRefresh}
|
>
|
||||||
onWorkspacePress={closeToAgent}
|
{({ hovered }) => (
|
||||||
parentGestureRef={closeGestureRef}
|
<>
|
||||||
/>
|
<MessagesSquare
|
||||||
)}
|
size={theme.iconSize.md}
|
||||||
|
|
||||||
{/* 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}
|
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
<Text
|
||||||
</Pressable>
|
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||||
</View>
|
>
|
||||||
<Combobox
|
Sessions
|
||||||
options={hostOptions}
|
</Text>
|
||||||
value={activeServerId ?? ''}
|
</>
|
||||||
onSelect={handleHostSelect}
|
)}
|
||||||
searchable={false}
|
</Pressable>
|
||||||
title="Switch host"
|
|
||||||
searchPlaceholder="Search hosts..."
|
|
||||||
open={isHostPickerOpen}
|
|
||||||
onOpenChange={setIsHostPickerOpen}
|
|
||||||
anchorRef={hostTriggerRef}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</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) {
|
if (!isOpen) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -474,7 +642,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Middle: scrollable project/workspace tree */}
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
) : (
|
) : (
|
||||||
@@ -490,7 +657,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
<View style={styles.footerHostSlot}>
|
<View style={styles.footerHostSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -502,7 +668,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
onPress={() => setIsHostPickerOpen(true)}
|
onPress={() => setIsHostPickerOpen(true)}
|
||||||
disabled={hostOptions.length === 0}
|
disabled={hostOptions.length === 0}
|
||||||
>
|
>
|
||||||
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
|
<View style={hostStatusDotStyle} />
|
||||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||||
{activeHostLabel}
|
{activeHostLabel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -519,7 +685,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
accessible
|
accessible
|
||||||
accessibilityLabel="Add project"
|
accessibilityLabel="Add project"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={handleOpenProjectDesktop}
|
onPress={handleOpenProject}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Plus
|
<Plus
|
||||||
@@ -544,7 +710,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
accessible
|
accessible
|
||||||
accessibilityLabel="Settings"
|
accessibilityLabel="Settings"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={handleSettingsDesktop}
|
onPress={handleSettings}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Settings
|
<Settings
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { usePathname } from "expo-router";
|
|||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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 { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
@@ -34,7 +34,8 @@ export function ProjectPickerModal() {
|
|||||||
return daemons[0]?.serverId ?? null;
|
return daemons[0]?.serverId ?? null;
|
||||||
}, [pathname, daemons]);
|
}, [pathname, daemons]);
|
||||||
|
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const workspaces = useSessionStore((state) =>
|
const workspaces = useSessionStore((state) =>
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined
|
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 { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import { type GestureType } from 'react-native-gesture-handler'
|
import { type GestureType } from 'react-native-gesture-handler'
|
||||||
import * as Clipboard from 'expo-clipboard'
|
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 { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||||
@@ -160,18 +160,26 @@ function resolveStatusDotColor(input: {
|
|||||||
|
|
||||||
function WorkspaceStatusIndicator({
|
function WorkspaceStatusIndicator({
|
||||||
bucket,
|
bucket,
|
||||||
|
workspaceKind,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
bucket: SidebarWorkspaceEntry['statusBucket']
|
bucket: SidebarWorkspaceEntry['statusBucket']
|
||||||
|
workspaceKind: SidebarWorkspaceEntry['workspaceKind']
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const color = resolveStatusDotColor({ theme, bucket })
|
const color = resolveStatusDotColor({ theme, bucket })
|
||||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
|
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
|
||||||
const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done'
|
const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done'
|
||||||
|
|
||||||
if (shouldRenderIdlePlaceholder) {
|
if (isIdle) {
|
||||||
return null
|
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 (
|
return (
|
||||||
@@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({
|
|||||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||||
) : shouldShowSyncedLoader ? (
|
) : shouldShowSyncedLoader ? (
|
||||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
<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 }]} />
|
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +225,7 @@ function ProjectLeadingVisual({
|
|||||||
{showChevron && chevron !== null ? (
|
{showChevron && chevron !== null ? (
|
||||||
<ProjectInlineChevron chevron={chevron} />
|
<ProjectInlineChevron chevron={chevron} />
|
||||||
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||||
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
|
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} workspaceKind={activeWorkspace.workspaceKind} loading={isArchiving} />
|
||||||
) : iconDataUri ? (
|
) : iconDataUri ? (
|
||||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||||
) : (
|
) : (
|
||||||
@@ -686,6 +698,7 @@ function WorkspaceRowInner({
|
|||||||
>
|
>
|
||||||
<WorkspaceStatusIndicator
|
<WorkspaceStatusIndicator
|
||||||
bucket={workspace.statusBucket}
|
bucket={workspace.statusBucket}
|
||||||
|
workspaceKind={workspace.workspaceKind}
|
||||||
loading={isArchiving || isCreating}
|
loading={isArchiving || isCreating}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
@@ -1903,7 +1916,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceRowLeft: {
|
workspaceRowLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[2],
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
@@ -1938,7 +1951,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
workspaceStatusDot: {
|
workspaceStatusDot: {
|
||||||
width: 11,
|
width: 14,
|
||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ interface SplitContainerProps {
|
|||||||
newTabAgentOptionId?: "__new_tab_agent__";
|
newTabAgentOptionId?: "__new_tab_agent__";
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
|
isPaneFocused: boolean;
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
}) => WorkspacePaneContentModel;
|
}) => WorkspacePaneContentModel;
|
||||||
onFocusPane: (paneId: string) => void;
|
onFocusPane: (paneId: string) => void;
|
||||||
@@ -751,6 +752,7 @@ function SplitPaneView({
|
|||||||
activeTabDescriptor
|
activeTabDescriptor
|
||||||
? buildPaneContentModel({
|
? buildPaneContentModel({
|
||||||
paneId: pane.id,
|
paneId: pane.id,
|
||||||
|
isPaneFocused: isFocused,
|
||||||
tab: activeTabDescriptor,
|
tab: activeTabDescriptor,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import Svg, {
|
|||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
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 { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
@@ -150,7 +150,8 @@ export function TerminalPane({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
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 scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
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 { useWindowDimensions } from "react-native";
|
||||||
import {
|
import {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -78,7 +78,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
}
|
}
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||||
|
|
||||||
const animateToOpen = () => {
|
const animateToOpen = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(0, {
|
translateX.value = withTiming(0, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
@@ -88,9 +88,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
};
|
}, [translateX, backdropOpacity]);
|
||||||
|
|
||||||
const animateToClose = () => {
|
const animateToClose = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(-windowWidth, {
|
translateX.value = withTiming(-windowWidth, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
@@ -100,20 +100,23 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
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 (
|
return (
|
||||||
<SidebarAnimationContext.Provider
|
<SidebarAnimationContext.Provider value={value}>
|
||||||
value={{
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
animateToOpen,
|
|
||||||
animateToClose,
|
|
||||||
isGesturing,
|
|
||||||
closeGestureRef,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</SidebarAnimationContext.Provider>
|
</SidebarAnimationContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-comm
|
|||||||
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
|
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
|
||||||
import { useAutocomplete } from './use-autocomplete'
|
import { useAutocomplete } from './use-autocomplete'
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
||||||
import {
|
import {
|
||||||
applyFileMentionReplacement,
|
applyFileMentionReplacement,
|
||||||
findActiveFileMention,
|
findActiveFileMention,
|
||||||
@@ -138,7 +138,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
return agentCwd.trim()
|
return agentCwd.trim()
|
||||||
}, [agentCwd, isDraftContext, queryDraftConfig])
|
}, [agentCwd, isDraftContext, queryDraftConfig])
|
||||||
|
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId)
|
const client = useHostRuntimeClient(serverId)
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId)
|
||||||
|
|
||||||
const mode: 'command' | 'file' | null = showFileAutocomplete
|
const mode: 'command' | 'file' | null = showFileAutocomplete
|
||||||
? 'file'
|
? 'file'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||||
@@ -48,7 +48,8 @@ export function useAgentCommandsQuery({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
draftConfig,
|
draftConfig,
|
||||||
}: UseAgentCommandsQueryOptions) {
|
}: UseAgentCommandsQueryOptions) {
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: commandsQueryKey(serverId, agentId, draftConfig),
|
queryKey: commandsQueryKey(serverId, agentId, draftConfig),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
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";
|
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
||||||
|
|
||||||
// Explicit overrides from URL params or "New Agent" button
|
// Explicit overrides from URL params or "New Agent" button
|
||||||
@@ -367,7 +367,8 @@ export function useAgentFormState(
|
|||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
// Session state for provider model listing
|
// 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({
|
const availableProvidersQuery = useQuery({
|
||||||
queryKey: ["availableProviders", formState.serverId],
|
queryKey: ["availableProviders", formState.serverId],
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useHosts } from "@/runtime/host-runtime";
|
|||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
isHostRuntimeDirectoryLoading,
|
useHostRuntimeConnectionStatus,
|
||||||
useHostRuntimeSession,
|
useHostRuntimeIsDirectoryLoading,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import type {
|
import type {
|
||||||
AggregatedAgent,
|
AggregatedAgent,
|
||||||
@@ -88,14 +88,14 @@ export function useAllAgentsList(options?: {
|
|||||||
const liveAgents = useSessionStore((state) =>
|
const liveAgents = useSessionStore((state) =>
|
||||||
serverId ? state.sessions[serverId]?.agents ?? null : null
|
serverId ? state.sessions[serverId]?.agents ?? null : null
|
||||||
);
|
);
|
||||||
const { snapshot } = useHostRuntimeSession(serverId ?? "");
|
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
if (!serverId || snapshot?.connectionStatus !== "online") {
|
if (!serverId || connectionStatus !== "online") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
||||||
}, [runtime, serverId, snapshot?.connectionStatus]);
|
}, [runtime, serverId, connectionStatus]);
|
||||||
|
|
||||||
const agents = useMemo(() => {
|
const agents = useMemo(() => {
|
||||||
if (!serverId || !liveAgents) {
|
if (!serverId || !liveAgents) {
|
||||||
@@ -111,7 +111,7 @@ export function useAllAgentsList(options?: {
|
|||||||
});
|
});
|
||||||
}, [daemons, includeArchived, liveAgents, serverId]);
|
}, [daemons, includeArchived, liveAgents, serverId]);
|
||||||
|
|
||||||
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
|
const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? "");
|
||||||
const isInitialLoad = isDirectoryLoading && agents.length === 0;
|
const isInitialLoad = isDirectoryLoading && agents.length === 0;
|
||||||
const isRevalidating = 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 { useCallback, useEffect, useId, useMemo } from "react";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
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 type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||||
|
|
||||||
@@ -56,7 +56,8 @@ export function useCheckoutDiffQuery({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutDiffQueryOptions) {
|
}: UseCheckoutDiffQueryOptions) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||||
|
|
||||||
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
||||||
@@ -21,7 +21,8 @@ export function useCheckoutPrStatusQuery({
|
|||||||
cwd,
|
cwd,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutPrStatusQueryOptions) {
|
}: UseCheckoutPrStatusQueryOptions) {
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
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 type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||||
import {
|
import {
|
||||||
checkoutStatusRevalidationKey,
|
checkoutStatusRevalidationKey,
|
||||||
@@ -30,7 +30,8 @@ function fetchCheckoutStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
@@ -87,7 +88,7 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
|
|||||||
* only the visible agents.
|
* only the visible agents.
|
||||||
*/
|
*/
|
||||||
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||||
const { client } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
@@ -13,7 +13,7 @@ export function useOpenProject(
|
|||||||
): (path: string) => Promise<boolean> {
|
): (path: string) => Promise<boolean> {
|
||||||
const normalizedServerId = serverId?.trim() ?? "";
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { client } = useHostRuntimeSession(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const setHasHydratedWorkspaces = useSessionStore(
|
const setHasHydratedWorkspaces = useSessionStore(
|
||||||
(state) => state.setHasHydratedWorkspaces
|
(state) => state.setHasHydratedWorkspaces
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import type { ProjectIcon } from "@server/shared/messages";
|
||||||
|
|
||||||
export function projectIconQueryKey(serverId: string, cwd: string) {
|
export function projectIconQueryKey(serverId: string, cwd: string) {
|
||||||
@@ -12,7 +12,8 @@ interface UseProjectIconQueryOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) {
|
export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) {
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: projectIconQueryKey(serverId, cwd),
|
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 { Bot } from "lucide-react-native";
|
||||||
import invariant from "tiny-invariant";
|
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 { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-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 { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
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 { 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 {
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||||
if (provider === "claude") {
|
if (provider === "claude") {
|
||||||
@@ -63,14 +104,13 @@ function useAgentPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AgentPanel() {
|
function AgentPanel() {
|
||||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
|
||||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||||
return (
|
return (
|
||||||
<AgentReadyScreen
|
<AgentPanelContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={target.agentId}
|
agentId={target.agentId}
|
||||||
showExplorerSidebar={false}
|
isPaneFocused={isPaneFocused}
|
||||||
wrapWithExplorerSidebarProvider={false}
|
|
||||||
onOpenWorkspaceFile={({ filePath }) => {
|
onOpenWorkspaceFile={({ filePath }) => {
|
||||||
openFileInWorkspace(filePath);
|
openFileInWorkspace(filePath);
|
||||||
}}
|
}}
|
||||||
@@ -83,3 +123,897 @@ export const agentPanelRegistration: PanelRegistration<"agent"> = {
|
|||||||
component: AgentPanel,
|
component: AgentPanel,
|
||||||
useDescriptor: useAgentPanelDescriptor,
|
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;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
|
isPaneFocused: boolean;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
openTab(target: WorkspaceTabTarget): void;
|
openTab(target: WorkspaceTabTarget): void;
|
||||||
closeCurrentTab(): void;
|
closeCurrentTab(): void;
|
||||||
|
|||||||
@@ -1734,17 +1734,13 @@ export function useHostRuntimeSnapshot(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeSession(serverId: string): {
|
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
||||||
snapshot: HostRuntimeSnapshot | null;
|
const store = getHostRuntimeStore();
|
||||||
client: DaemonClient | null;
|
return useSyncExternalStore(
|
||||||
isConnected: boolean;
|
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||||
} {
|
() => store.getSnapshot(serverId)?.client ?? null,
|
||||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
() => store.getSnapshot(serverId)?.client ?? null
|
||||||
return {
|
);
|
||||||
snapshot,
|
|
||||||
client: snapshot?.client ?? null,
|
|
||||||
isConnected: isHostRuntimeConnected(snapshot),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHostRuntimeIsConnected(serverId: string): boolean {
|
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[] {
|
export function useHosts(): HostProfile[] {
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
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 { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
import { generateDraftId } from '@/stores/draft-keys'
|
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 { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
|
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
|
||||||
import { MAX_CONTENT_WIDTH } from '@/constants/layout'
|
import { MAX_CONTENT_WIDTH } from '@/constants/layout'
|
||||||
@@ -317,9 +317,8 @@ function DraftAgentScreenContent({
|
|||||||
return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources])
|
return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources])
|
||||||
}, [allAgents, sessionAgents])
|
}, [allAgents, sessionAgents])
|
||||||
|
|
||||||
const { client: runtimeClient, isConnected: isHostOnline } = useHostRuntimeSession(
|
const runtimeClient = useHostRuntimeClient(selectedServerId ?? '')
|
||||||
selectedServerId ?? ''
|
const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? '')
|
||||||
)
|
|
||||||
const sessionClient = runtimeClient
|
const sessionClient = runtimeClient
|
||||||
const trimmedWorkingDir = workingDir.trim()
|
const trimmedWorkingDir = workingDir.trim()
|
||||||
const shouldInspectRepo = trimmedWorkingDir.length > 0
|
const shouldInspectRepo = trimmedWorkingDir.length > 0
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
isHostRuntimeConnected,
|
isHostRuntimeConnected,
|
||||||
useHostRuntimeSession,
|
useHostRuntimeClient,
|
||||||
|
useHostRuntimeIsConnected,
|
||||||
|
useHostRuntimeSnapshot,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||||
import { AddHostModal } from "@/components/add-host-modal";
|
import { AddHostModal } from "@/components/add-host-modal";
|
||||||
@@ -993,9 +995,9 @@ function HostDetailModal({
|
|||||||
const connections = host?.connections ?? [];
|
const connections = host?.connections ?? [];
|
||||||
|
|
||||||
// Restart logic (moved from DaemonCard)
|
// Restart logic (moved from DaemonCard)
|
||||||
const { snapshot: runtimeSnapshot, client: runtimeClient, isConnected } = useHostRuntimeSession(
|
const runtimeSnapshot = useHostRuntimeSnapshot(host?.serverId ?? "");
|
||||||
host?.serverId ?? ""
|
const runtimeClient = useHostRuntimeClient(host?.serverId ?? "");
|
||||||
);
|
const isConnected = useHostRuntimeIsConnected(host?.serverId ?? "");
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const daemonClient = runtimeClient;
|
const daemonClient = runtimeClient;
|
||||||
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
|
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
|
||||||
@@ -1390,7 +1392,7 @@ function DaemonCard({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: DaemonCardProps) {
|
}: DaemonCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { snapshot } = useHostRuntimeSession(daemon.serverId);
|
const snapshot = useHostRuntimeSnapshot(daemon.serverId);
|
||||||
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||||
const activeConnection = snapshot?.activeConnection ?? null;
|
const activeConnection = snapshot?.activeConnection ?? null;
|
||||||
const lastError = snapshot?.lastError ?? null;
|
const lastError = snapshot?.lastError ?? null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
canOpenAgentTabFromRoute,
|
canOpenAgentTabFromRoute,
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
shouldPruneWorkspaceAgentTab,
|
shouldPruneWorkspaceAgentTab,
|
||||||
|
workspaceAgentVisibilityEqual,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} from "@/screens/workspace/workspace-agent-visibility";
|
||||||
|
|
||||||
function makeAgent(input: {
|
function makeAgent(input: {
|
||||||
@@ -53,7 +54,7 @@ function makeAgent(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("workspace agent visibility", () => {
|
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 workspaceId = "/repo/worktree";
|
||||||
const visible = makeAgent({
|
const visible = makeAgent({
|
||||||
id: "visible-agent",
|
id: "visible-agent",
|
||||||
@@ -82,87 +83,46 @@ describe("workspace agent visibility", () => {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["visible-agent"]);
|
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
||||||
expect(result.lookupById.has("visible-agent")).toBe(true);
|
expect(result.knownAgentIds.has("visible-agent")).toBe(true);
|
||||||
expect(result.lookupById.has("archived-agent")).toBe(true);
|
expect(result.knownAgentIds.has("archived-agent")).toBe(true);
|
||||||
expect(result.lookupById.has("other-workspace-agent")).toBe(false);
|
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows explicit route open for archived agent once agents are hydrated", () => {
|
it("allows explicit route open for archived agent once agents are hydrated", () => {
|
||||||
const archivedAgent = makeAgent({
|
const knownAgentIds = new Set(["archived-agent"]);
|
||||||
id: "archived-agent",
|
|
||||||
cwd: "/repo/worktree",
|
|
||||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
|
||||||
});
|
|
||||||
const lookup = new Map<string, Agent>([[archivedAgent.id, archivedAgent]]);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canOpenAgentTabFromRoute({
|
canOpenAgentTabFromRoute({
|
||||||
agentId: "archived-agent",
|
agentId: "archived-agent",
|
||||||
agentsHydrated: true,
|
agentsHydrated: true,
|
||||||
workspaceAgentLookup: lookup,
|
knownAgentIds,
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not prune archived agent tabs when the workspace lookup still contains the agent", () => {
|
it("does not prune archived agent tabs when knownAgentIds contains the agent", () => {
|
||||||
const archivedAgent = makeAgent({
|
const knownAgentIds = new Set(["archived-agent"]);
|
||||||
id: "archived-agent",
|
|
||||||
cwd: "/repo/worktree",
|
|
||||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
|
||||||
});
|
|
||||||
const lookup = new Map<string, Agent>([[archivedAgent.id, archivedAgent]]);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
shouldPruneWorkspaceAgentTab({
|
shouldPruneWorkspaceAgentTab({
|
||||||
agentId: "archived-agent",
|
agentId: "archived-agent",
|
||||||
agentsHydrated: true,
|
agentsHydrated: true,
|
||||||
workspaceAgentLookup: lookup,
|
knownAgentIds,
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).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(
|
expect(
|
||||||
shouldPruneWorkspaceAgentTab({
|
shouldPruneWorkspaceAgentTab({
|
||||||
agentId: "missing-agent",
|
agentId: "missing-agent",
|
||||||
agentsHydrated: true,
|
agentsHydrated: true,
|
||||||
workspaceAgentLookup: new Map<string, Agent>(),
|
knownAgentIds: new Set<string>(),
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).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", () => {
|
it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => {
|
||||||
const sessionAgents = new Map<string, Agent>([
|
const sessionAgents = new Map<string, Agent>([
|
||||||
[
|
[
|
||||||
@@ -179,7 +139,33 @@ describe("workspace agent visibility", () => {
|
|||||||
workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["slash-agent"]);
|
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||||
expect(result.lookupById.has("slash-agent")).toBe(true);
|
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 type { Agent } from "@/stores/session-store";
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
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 {
|
function normalizeWorkspaceId(value: string | null | undefined): string {
|
||||||
return normalizeWorkspaceIdentity(value) ?? "";
|
return normalizeWorkspaceIdentity(value) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceAgentVisibility {
|
||||||
|
activeAgentIds: Set<string>;
|
||||||
|
knownAgentIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveWorkspaceAgentVisibility(input: {
|
export function deriveWorkspaceAgentVisibility(input: {
|
||||||
sessionAgents: Map<string, Agent> | undefined;
|
sessionAgents: Map<string, Agent> | undefined;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}): {
|
}): WorkspaceAgentVisibility {
|
||||||
visibleAgents: Agent[];
|
|
||||||
lookupById: Map<string, Agent>;
|
|
||||||
} {
|
|
||||||
const { sessionAgents, workspaceId } = input;
|
const { sessionAgents, workspaceId } = input;
|
||||||
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
||||||
if (!sessionAgents || !workspaceId) {
|
if (!sessionAgents || !workspaceId) {
|
||||||
return {
|
return {
|
||||||
visibleAgents: [],
|
activeAgentIds: new Set<string>(),
|
||||||
lookupById: new Map<string, Agent>(),
|
knownAgentIds: new Set<string>(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookupById = new Map<string, Agent>();
|
const activeAgentIds = new Set<string>();
|
||||||
const visible: Agent[] = [];
|
const knownAgentIds = new Set<string>();
|
||||||
for (const agent of sessionAgents.values()) {
|
for (const agent of sessionAgents.values()) {
|
||||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
|
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
lookupById.set(agent.id, agent);
|
knownAgentIds.add(agent.id);
|
||||||
if (!agent.archivedAt) {
|
if (!agent.archivedAt) {
|
||||||
visible.push(agent);
|
activeAgentIds.add(agent.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { activeAgentIds, knownAgentIds };
|
||||||
visibleAgents: sortAgentsByCreatedAtDescending(visible),
|
}
|
||||||
lookupById,
|
|
||||||
};
|
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: {
|
export function canOpenAgentTabFromRoute(input: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentsHydrated: boolean;
|
agentsHydrated: boolean;
|
||||||
workspaceAgentLookup: Map<string, Agent>;
|
knownAgentIds: Set<string>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!input.agentId.trim()) {
|
if (!input.agentId.trim()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -70,13 +68,13 @@ export function canOpenAgentTabFromRoute(input: {
|
|||||||
if (!input.agentsHydrated) {
|
if (!input.agentsHydrated) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return input.workspaceAgentLookup.has(input.agentId);
|
return input.knownAgentIds.has(input.agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldPruneWorkspaceAgentTab(input: {
|
export function shouldPruneWorkspaceAgentTab(input: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentsHydrated: boolean;
|
agentsHydrated: boolean;
|
||||||
workspaceAgentLookup: Map<string, Agent>;
|
knownAgentIds: Set<string>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!input.agentId.trim()) {
|
if (!input.agentId.trim()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -84,5 +82,5 @@ export function shouldPruneWorkspaceAgentTab(input: {
|
|||||||
if (!input.agentsHydrated) {
|
if (!input.agentsHydrated) {
|
||||||
return false;
|
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 type { ImageAttachment } from '@/components/message-input'
|
||||||
import { useAgentFormState } from '@/hooks/use-agent-form-state'
|
import { useAgentFormState } from '@/hooks/use-agent-form-state'
|
||||||
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
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 type { Agent } from '@/stores/session-store'
|
||||||
import { encodeImages } from '@/utils/encode-images'
|
import { encodeImages } from '@/utils/encode-images'
|
||||||
import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types'
|
import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types'
|
||||||
@@ -40,7 +40,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
onCreated,
|
onCreated,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: WorkspaceDraftAgentTabProps) {
|
}: WorkspaceDraftAgentTabProps) {
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId)
|
const client = useHostRuntimeClient(serverId)
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId)
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface BuildWorkspacePaneContentModelInput {
|
|||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
|
isPaneFocused: boolean;
|
||||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
onCloseCurrentTab: () => void;
|
onCloseCurrentTab: () => void;
|
||||||
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
@@ -25,6 +26,7 @@ export function buildWorkspacePaneContentModel({
|
|||||||
tab,
|
tab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isPaneFocused,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onCloseCurrentTab,
|
onCloseCurrentTab,
|
||||||
onRetargetCurrentTab,
|
onRetargetCurrentTab,
|
||||||
@@ -40,6 +42,7 @@ export function buildWorkspacePaneContentModel({
|
|||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
|
isPaneFocused,
|
||||||
target: tab.target,
|
target: tab.target,
|
||||||
openTab: onOpenTab,
|
openTab: onOpenTab,
|
||||||
closeCurrentTab: onCloseCurrentTab,
|
closeCurrentTab: onCloseCurrentTab,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -70,7 +71,7 @@ import {
|
|||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
} from "@/utils/workspace-tab-identity";
|
} 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 { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||||
import {
|
import {
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
@@ -96,6 +97,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
shouldPruneWorkspaceAgentTab,
|
shouldPruneWorkspaceAgentTab,
|
||||||
|
workspaceAgentVisibilityEqual,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} from "@/screens/workspace/workspace-agent-visibility";
|
||||||
import {
|
import {
|
||||||
deriveWorkspacePaneState,
|
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({
|
function MobileWorkspaceTabOption({
|
||||||
tab,
|
tab,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
@@ -519,6 +540,12 @@ export function WorkspaceScreen({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
openIntent,
|
openIntent,
|
||||||
}: WorkspaceScreenProps) {
|
}: WorkspaceScreenProps) {
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
|
if (!isFocused) {
|
||||||
|
return <View style={{ flex: 1 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExplorerSidebarAnimationProvider>
|
<ExplorerSidebarAnimationProvider>
|
||||||
<WorkspaceScreenContent
|
<WorkspaceScreenContent
|
||||||
@@ -539,7 +566,6 @@ function WorkspaceScreenContent({
|
|||||||
const isDarkMode = useColorScheme() === "dark";
|
const isDarkMode = useColorScheme() === "dark";
|
||||||
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
|
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const isScreenFocused = useIsFocused();
|
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
@@ -555,20 +581,18 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
|
|
||||||
const sessionAgents = useSessionStore(
|
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||||
(state) => state.sessions[normalizedServerId]?.agents
|
useSessionStore,
|
||||||
);
|
(state) =>
|
||||||
const workspaceAgentVisibility = useMemo(
|
|
||||||
() =>
|
|
||||||
deriveWorkspaceAgentVisibility({
|
deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents,
|
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
}),
|
}),
|
||||||
[normalizedWorkspaceId, sessionAgents]
|
workspaceAgentVisibilityEqual,
|
||||||
);
|
);
|
||||||
const workspaceAgents = workspaceAgentVisibility.visibleAgents;
|
|
||||||
|
|
||||||
const terminalsQueryKey = useMemo(
|
const terminalsQueryKey = useMemo(
|
||||||
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
|
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
|
||||||
@@ -798,8 +822,6 @@ function WorkspaceScreenContent({
|
|||||||
return () => handler.remove();
|
return () => handler.remove();
|
||||||
}, [closeToAgent, isExplorerOpen]);
|
}, [closeToAgent, isExplorerOpen]);
|
||||||
|
|
||||||
const agentsById = workspaceAgentVisibility.lookupById;
|
|
||||||
|
|
||||||
const persistenceKey = useMemo(
|
const persistenceKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildWorkspaceTabPersistenceKey({
|
buildWorkspaceTabPersistenceKey({
|
||||||
@@ -828,7 +850,6 @@ function WorkspaceScreenContent({
|
|||||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
const consumedOpenIntentsRef = useRef(new Set<string>());
|
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||||
const reopenedArchivedOpenIntentsRef = useRef(new Set<string>());
|
|
||||||
const pendingCloseTabIdsRef = useRef(new Set<string>());
|
const pendingCloseTabIdsRef = useRef(new Set<string>());
|
||||||
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
||||||
const currentOpenIntentKey = useMemo(
|
const currentOpenIntentKey = useMemo(
|
||||||
@@ -849,6 +870,24 @@ function WorkspaceScreenContent({
|
|||||||
}),
|
}),
|
||||||
[uiTabs, workspaceLayout]
|
[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(
|
const ensureWorkspaceTab = useCallback(
|
||||||
function ensureWorkspaceTab(target: WorkspaceTabTarget): string | null {
|
function ensureWorkspaceTab(target: WorkspaceTabTarget): string | null {
|
||||||
@@ -914,43 +953,6 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!openIntent || !persistenceKey) {
|
if (!openIntent || !persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -1032,12 +1034,12 @@ function WorkspaceScreenContent({
|
|||||||
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const agent of workspaceAgents) {
|
for (const agentId of workspaceAgentVisibility.activeAgentIds) {
|
||||||
const representedByTarget = uiTabs.some(
|
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(
|
const representedByDeterministicTabId = uiTabs.some(
|
||||||
(tab) => tab.tabId === `agent_${agent.id}`
|
(tab) => tab.tabId === `agent_${agentId}`
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hasActivePendingDraftCreateInWorkspace &&
|
hasActivePendingDraftCreateInWorkspace &&
|
||||||
@@ -1046,7 +1048,7 @@ function WorkspaceScreenContent({
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ensureWorkspaceTab({ kind: "agent", agentId: agent.id });
|
ensureWorkspaceTab({ kind: "agent", agentId });
|
||||||
}
|
}
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id });
|
ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id });
|
||||||
@@ -1061,7 +1063,7 @@ function WorkspaceScreenContent({
|
|||||||
shouldPruneWorkspaceAgentTab({
|
shouldPruneWorkspaceAgentTab({
|
||||||
agentId: tab.target.agentId,
|
agentId: tab.target.agentId,
|
||||||
agentsHydrated: hasHydratedAgents,
|
agentsHydrated: hasHydratedAgents,
|
||||||
workspaceAgentLookup: agentsById,
|
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
closeWorkspaceTab(persistenceKey, tab.tabId);
|
closeWorkspaceTab(persistenceKey, tab.tabId);
|
||||||
@@ -1083,7 +1085,7 @@ function WorkspaceScreenContent({
|
|||||||
terminals,
|
terminals,
|
||||||
terminalsQuery.isSuccess,
|
terminalsQuery.isSuccess,
|
||||||
uiTabs,
|
uiTabs,
|
||||||
workspaceAgents,
|
workspaceAgentVisibility,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeTabId = resolvedPaneTabState.activeTabId;
|
const activeTabId = resolvedPaneTabState.activeTabId;
|
||||||
@@ -1137,7 +1139,7 @@ function WorkspaceScreenContent({
|
|||||||
emptyWorkspaceSeedRef.current = null;
|
emptyWorkspaceSeedRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (workspaceAgents.length > 0 || terminals.length > 0) {
|
if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) {
|
||||||
emptyWorkspaceSeedRef.current = null;
|
emptyWorkspaceSeedRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1159,7 +1161,7 @@ function WorkspaceScreenContent({
|
|||||||
persistenceKey,
|
persistenceKey,
|
||||||
terminals.length,
|
terminals.length,
|
||||||
tabs.length,
|
tabs.length,
|
||||||
workspaceAgents.length,
|
workspaceAgentVisibility.activeAgentIds.size,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleOpenFileFromExplorer = useCallback(
|
const handleOpenFileFromExplorer = useCallback(
|
||||||
@@ -1442,7 +1444,7 @@ function WorkspaceScreenContent({
|
|||||||
const handleCopyResumeCommand = useCallback(
|
const handleCopyResumeCommand = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
const agent = sessionAgents?.get(agentId) ?? null;
|
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
const providerSessionId =
|
const providerSessionId =
|
||||||
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
|
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
|
||||||
if (!agent || !providerSessionId) {
|
if (!agent || !providerSessionId) {
|
||||||
@@ -1467,7 +1469,7 @@ function WorkspaceScreenContent({
|
|||||||
toast.error("Copy failed");
|
toast.error("Copy failed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionAgents, toast]
|
[normalizedServerId, toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyWorkspacePath = useCallback(async () => {
|
const handleCopyWorkspacePath = useCallback(async () => {
|
||||||
@@ -1773,7 +1775,7 @@ function WorkspaceScreenContent({
|
|||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => isScreenFocused,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceTabAction,
|
handle: handleWorkspaceTabAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1794,21 +1796,29 @@ function WorkspaceScreenContent({
|
|||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => isScreenFocused,
|
isActive: () => true,
|
||||||
handle: handleWorkspacePaneAction,
|
handle: handleWorkspacePaneAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTabDescriptor = activeTab?.descriptor ?? null;
|
const activeTabDescriptor = activeTab?.descriptor ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.title = "Workspace";
|
||||||
|
}, [activeTabDescriptor]);
|
||||||
const buildPaneContentModel = useCallback(
|
const buildPaneContentModel = useCallback(
|
||||||
(input: {
|
(input: {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
paneId?: string | null;
|
paneId?: string | null;
|
||||||
|
isPaneFocused: boolean;
|
||||||
focusPaneBeforeOpen?: boolean;
|
focusPaneBeforeOpen?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
buildWorkspacePaneContentModel({
|
buildWorkspacePaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
isPaneFocused: input.isPaneFocused,
|
||||||
onOpenTab: (target) => {
|
onOpenTab: (target) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -1855,6 +1865,7 @@ function WorkspaceScreenContent({
|
|||||||
? buildPaneContentModel({
|
? buildPaneContentModel({
|
||||||
tab: activeTabDescriptor,
|
tab: activeTabDescriptor,
|
||||||
paneId: focusedPaneTabState.pane?.id ?? null,
|
paneId: focusedPaneTabState.pane?.id ?? null,
|
||||||
|
isPaneFocused: true,
|
||||||
focusPaneBeforeOpen: false,
|
focusPaneBeforeOpen: false,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
@@ -1885,6 +1896,20 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
|
<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.threePaneRow}>
|
||||||
<View style={styles.centerColumn}>
|
<View style={styles.centerColumn}>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
@@ -2115,13 +2140,14 @@ function WorkspaceScreenContent({
|
|||||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||||
onSelectNewTabOption={handleSelectNewTabOption}
|
onSelectNewTabOption={handleSelectNewTabOption}
|
||||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||||
buildPaneContentModel={({ paneId, tab }) =>
|
buildPaneContentModel={({ paneId, tab, isPaneFocused }) =>
|
||||||
buildPaneContentModel({
|
buildPaneContentModel({
|
||||||
tab,
|
tab,
|
||||||
paneId,
|
paneId,
|
||||||
focusPaneBeforeOpen: true,
|
isPaneFocused,
|
||||||
})
|
focusPaneBeforeOpen: true,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
onFocusPane={(paneId) => {
|
onFocusPane={(paneId) => {
|
||||||
focusWorkspacePane(persistenceKey, paneId);
|
focusWorkspacePane(persistenceKey, paneId);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ class DaemonRpcError extends Error {
|
|||||||
readonly code?: string
|
readonly code?: string
|
||||||
|
|
||||||
constructor(params: { requestId: string; error: string; requestType?: string; 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.name = 'DaemonRpcError'
|
||||||
this.requestId = params.requestId
|
this.requestId = params.requestId
|
||||||
this.requestType = params.requestType
|
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", () => {
|
test("passes assistant content blocks through to the mapper", () => {
|
||||||
const entry = {
|
const entry = {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
mapClaudeRunningToolCall,
|
mapClaudeRunningToolCall,
|
||||||
} from "./claude/tool-call-mapper.js";
|
} from "./claude/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
|
coerceTaskNotificationHistoryRecordToSystemMessage,
|
||||||
isTaskNotificationUserContent,
|
isTaskNotificationUserContent,
|
||||||
mapTaskNotificationSystemRecordToToolCall,
|
mapTaskNotificationSystemRecordToToolCall,
|
||||||
mapTaskNotificationUserContentToToolCall,
|
mapTaskNotificationUserContentToToolCall,
|
||||||
@@ -1495,6 +1496,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private readonly timelineAssembler = new TimelineAssembler();
|
private readonly timelineAssembler = new TimelineAssembler();
|
||||||
private persistedHistory: AgentTimelineItem[] = [];
|
private persistedHistory: AgentTimelineItem[] = [];
|
||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
|
private historyOffsetSessionId: string | null = null;
|
||||||
|
private historyReadOffsetBytes = 0;
|
||||||
|
private historyLineFragment = "";
|
||||||
private turnState: TurnState = "idle";
|
private turnState: TurnState = "idle";
|
||||||
private preReplayMetadataSeen = false;
|
private preReplayMetadataSeen = false;
|
||||||
private pendingAutonomousWakeReservations = 0;
|
private pendingAutonomousWakeReservations = 0;
|
||||||
@@ -3074,6 +3078,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
this.persistedHistory = [];
|
this.persistedHistory = [];
|
||||||
this.historyPending = false;
|
this.historyPending = false;
|
||||||
|
this.historyOffsetSessionId = null;
|
||||||
|
this.historyReadOffsetBytes = 0;
|
||||||
|
this.historyLineFragment = "";
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
this.queryRestartNeeded = false;
|
this.queryRestartNeeded = false;
|
||||||
return true;
|
return true;
|
||||||
@@ -3998,46 +4005,163 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private waitForLiveHistoryPoll(): Promise<void> {
|
private async waitForLiveHistoryPoll(): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, 250));
|
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 {
|
try {
|
||||||
const historyPath = this.resolveHistoryPath(sessionId);
|
const historyPath = this.resolveHistoryPath(sessionId);
|
||||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = fs.readFileSync(historyPath, "utf8");
|
if (this.historyOffsetSessionId !== sessionId) {
|
||||||
const timeline: AgentTimelineItem[] = [];
|
this.historyOffsetSessionId = sessionId;
|
||||||
for (const line of content.split(/\n+/)) {
|
this.historyReadOffsetBytes = 0;
|
||||||
const trimmed = line.trim();
|
this.historyLineFragment = "";
|
||||||
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 (timeline.length > 0) {
|
const content = fs.readFileSync(historyPath);
|
||||||
this.persistedHistory = timeline;
|
if (content.byteLength < this.historyReadOffsetBytes) {
|
||||||
this.historyPending = true;
|
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) {
|
} catch (error) {
|
||||||
// ignore history load failures
|
// 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 {
|
private resolveHistoryPath(sessionId: string): string | null {
|
||||||
const cwd = this.config.cwd;
|
const cwd = this.config.cwd;
|
||||||
if (!cwd) return null;
|
if (!cwd) return null;
|
||||||
@@ -4624,13 +4748,9 @@ export function convertClaudeHistoryEntry(
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === "system") {
|
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry);
|
||||||
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(
|
if (taskNotificationItem) {
|
||||||
entry
|
return [taskNotificationItem];
|
||||||
);
|
|
||||||
if (taskNotificationItem) {
|
|
||||||
return [taskNotificationItem];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isCompactSummary) {
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
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 LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
|
||||||
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||||
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_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() {
|
function buildSdkQueryMock() {
|
||||||
const events = [
|
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 {
|
function collectTimelineText(events: AgentStreamEvent[]): string {
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -86,6 +101,28 @@ function collectTimelineText(events: AgentStreamEvent[]): string {
|
|||||||
return chunks.join("\n");
|
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", () => {
|
describe("ClaudeAgentSession history replay regression", () => {
|
||||||
let tempRoot: string;
|
let tempRoot: string;
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
@@ -215,6 +252,117 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
|
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 () => {
|
test("listCommands includes rewind command", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ const TaskNotificationUserContentSchema = z.union([
|
|||||||
z.array(TaskNotificationUserContentBlockSchema),
|
z.array(TaskNotificationUserContentBlockSchema),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TaskNotificationSystemRecordSchema = z
|
const TaskNotificationHistoryRecordSchema = z
|
||||||
.object({
|
.object({
|
||||||
subtype: z.literal("task_notification"),
|
type: z.string().optional(),
|
||||||
|
subtype: z.string().optional(),
|
||||||
uuid: z.string().optional(),
|
uuid: z.string().optional(),
|
||||||
message_id: z.string().optional(),
|
message_id: z.string().optional(),
|
||||||
task_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 TaskNotificationUserContent = z.infer<typeof TaskNotificationUserContentSchema>;
|
||||||
type TaskNotificationSystemRecord = z.infer<typeof TaskNotificationSystemRecordSchema>;
|
type TaskNotificationHistoryRecord = z.infer<typeof TaskNotificationHistoryRecordSchema>;
|
||||||
|
|
||||||
type TaskNotificationLifecycle =
|
type TaskNotificationLifecycle =
|
||||||
| { status: "completed"; error: null }
|
| { status: "completed"; error: null }
|
||||||
| { status: "failed"; error: unknown }
|
| { status: "failed"; error: unknown }
|
||||||
| { status: "canceled"; error: null };
|
| { 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 = {
|
type ReadTaskNotificationTagInput = {
|
||||||
text: string;
|
text: string;
|
||||||
tagName: string;
|
tagName: string;
|
||||||
@@ -157,12 +169,18 @@ function parseTaskNotificationFromUserContent(
|
|||||||
function parseTaskNotificationFromSystemRecord(
|
function parseTaskNotificationFromSystemRecord(
|
||||||
record: unknown
|
record: unknown
|
||||||
): TaskNotificationEnvelope | null {
|
): TaskNotificationEnvelope | null {
|
||||||
const parsedRecord = TaskNotificationSystemRecordSchema.safeParse(record);
|
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
|
||||||
if (!parsedRecord.success) {
|
if (!parsedRecord.success) {
|
||||||
return null;
|
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);
|
const rawText = toNonEmptyString(systemRecord.content);
|
||||||
|
|
||||||
return TaskNotificationEnvelopeSchema.parse({
|
return TaskNotificationEnvelopeSchema.parse({
|
||||||
@@ -330,3 +348,31 @@ export function mapTaskNotificationSystemRecordToToolCall(
|
|||||||
}
|
}
|
||||||
return toTaskNotificationToolCall(parsed);
|
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: {
|
payload: {
|
||||||
requestId,
|
requestId,
|
||||||
requestType: msg.type,
|
requestType: msg.type,
|
||||||
error: 'Request failed',
|
error: `Request failed: ${err.message}`,
|
||||||
code: 'handler_error',
|
code: 'handler_error',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1109,36 +1109,44 @@ export async function getCheckoutShortstat(
|
|||||||
|
|
||||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||||
if (!localBaseRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
if (currentBranch === localBaseRef) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(
|
let diffTarget: string;
|
||||||
cwd,
|
|
||||||
normalizeLocalBranchRefName(localBaseRef)
|
|
||||||
);
|
|
||||||
|
|
||||||
let mergeBase: string;
|
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
||||||
try {
|
// Feature branch: diff against the merge-base with the base branch
|
||||||
const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, {
|
const comparisonBaseRef = await resolveBestComparisonBaseRef(
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
normalizeLocalBranchRefName(localBaseRef)
|
||||||
});
|
);
|
||||||
mergeBase = stdout.trim();
|
|
||||||
if (!mergeBase) {
|
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;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user