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

# Conflicts:
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/left-sidebar.tsx
This commit is contained in:
Mohamed Boudra
2026-03-21 01:52:25 +07:00
36 changed files with 2169 additions and 1769 deletions
@@ -28,7 +28,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Shortcut } from '@/components/ui/shortcut'
import { Autocomplete } from '@/components/ui/autocomplete'
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import {
deleteAttachments,
persistAttachmentFromBlob,
@@ -100,14 +100,16 @@ export function AgentInputArea({
const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused()
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId)
const toast = useToast()
const voice = useVoiceOptional()
const isDictationReady =
isConnected &&
(snapshot?.agentDirectoryStatus === 'ready' ||
snapshot?.agentDirectoryStatus === 'revalidating' ||
snapshot?.agentDirectoryStatus === 'error_after_ready')
(agentDirectoryStatus === 'ready' ||
agentDirectoryStatus === 'revalidating' ||
agentDirectoryStatus === 'error_after_ready')
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
+418 -252
View File
@@ -1,4 +1,15 @@
import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'
import {
memo,
useCallback,
useMemo,
useState,
useEffect,
useRef,
useSyncExternalStore,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Animated, {
@@ -18,7 +29,7 @@ import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
@@ -34,12 +45,58 @@ import {
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
const DESKTOP_SIDEBAR_WIDTH = 320
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
interface LeftSidebarProps {
selectedAgentId?: string
}
export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
interface HostOption {
id: string
label: string
description: string
}
interface SidebarSharedProps {
theme: SidebarTheme
activeServerId: string | null
activeHostLabel: string
activeHostStatusColor: string
hostOptions: HostOption[]
hostTriggerRef: RefObject<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
projects: SidebarProjectEntry[]
isInitialLoad: boolean
isRevalidating: boolean
isManualRefresh: boolean
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
handleRefresh: () => void
handleHostSelect: (nextServerId: string) => void
handleOpenProject: () => void
handleSettings: () => void
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number
insetsBottom: number
isOpen: boolean
closeToAgent: () => void
handleViewMoreNavigate: () => void
}
interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean
handleViewMore: () => void
}
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
void _selectedAgentId
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
@@ -109,10 +166,9 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
})),
[daemons, runtime, runtimeConnectionStatusSignature]
)
const hostTriggerRef = useRef<View>(null)
const hostTriggerRef = useRef<View | null>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
@@ -121,21 +177,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false)
const handleRefresh = useCallback(() => {
@@ -143,17 +185,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
refreshAll()
}, [refreshAll])
// Reset manual refresh flag when revalidation completes
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false)
}
}, [isRevalidating, isManualRefresh])
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
const openProjectPicker = useOpenProjectPicker(activeServerId)
const handleOpenProjectMobile = useCallback(() => {
@@ -165,7 +202,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
void openProjectPicker()
}, [openProjectPicker])
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return
@@ -174,7 +210,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
// Desktop: just navigate, don't close
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return
@@ -182,17 +217,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
const handleViewMore = useCallback(() => {
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return
}
if (isMobile) {
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
}
router.push(buildHostAgentsRoute(activeServerId) as any)
}, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
}, [activeServerId])
const handleHostSelect = useCallback(
(nextServerId: string) => {
@@ -206,77 +236,201 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
[pathname]
)
// Close gesture (swipe left to close when sidebar is open)
const closeGesture = Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isOpen)
// Use manual activation so child views keep touch streams unless we detect
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const sharedProps = {
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
isInitialLoad,
isRevalidating,
isManualRefresh,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
if (isMobile) {
return (
<MobileSidebar
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
isOpen={isOpen}
closeToAgent={closeToAgent}
handleOpenProject={handleOpenProjectMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
}
// Fail quickly on clear rightward or vertical intent so child views keep control.
if (deltaX >= 10) {
stateManager.fail()
return
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
}
return (
<DesktopSidebar
{...sharedProps}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
}
})
.onStart(() => {
isGesturing.value = true
})
.onUpdate((event) => {
if (!isMobile) return
// Only allow swiping left (closing)
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
})
.onEnd((event) => {
isGesturing.value = false
if (!isMobile) return
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
} else {
animateToOpen()
}
})
.onFinalize(() => {
isGesturing.value = false
})
function MobileSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
isInitialLoad,
isRevalidating,
isManualRefresh,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
handleSettings,
insetsTop,
insetsBottom,
isOpen,
closeToAgent,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return
}
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
handleViewMoreNavigate()
}, [
activeServerId,
backdropOpacity,
closeToAgent,
handleViewMoreNavigate,
translateX,
windowWidth,
])
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isOpen)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
if (deltaX >= 10) {
stateManager.fail()
return
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
}
})
.onStart(() => {
isGesturing.value = true
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
})
.onEnd((event) => {
isGesturing.value = false
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
} else {
animateToOpen()
}
})
.onFinalize(() => {
isGesturing.value = false
}),
[
isOpen,
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
isGesturing,
windowWidth,
translateX,
backdropOpacity,
animateToClose,
animateToOpen,
handleClose,
]
)
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom]
)
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
@@ -287,160 +441,174 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
}))
// Render mobile sidebar
// On web, keep the overlay interactive only while the sidebar is open.
// This preserves swipe/scroll behavior without blocking taps when closed.
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
if (isMobile) {
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
sidebarAnimatedStyle,
]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header */}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
hovered && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
</View>
</View>
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProjectMobile}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
</View>
</View>
</Animated.View>
</GestureDetector>
</View>
)
}
// Desktop: no edge swipe, just show/hide based on isOpen
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
parentGestureRef={closeGestureRef}
/>
)}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
</View>
</Animated.View>
</GestureDetector>
</View>
)
}
function DesktopSidebar({
theme,
activeServerId,
activeHostLabel,
activeHostStatusColor,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
projects,
isInitialLoad,
isRevalidating,
isManualRefresh,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
handleSettings,
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
if (!isOpen) {
return null
}
@@ -474,7 +642,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
</View>
</View>
{/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
@@ -490,7 +657,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
@@ -502,7 +668,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
@@ -519,7 +685,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProjectDesktop}
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
@@ -544,7 +710,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsDesktop}
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
@@ -15,7 +15,7 @@ import { usePathname } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import { parseServerIdFromPathname } from "@/utils/host-routes";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
@@ -34,7 +34,8 @@ export function ProjectPickerModal() {
return daemons[0]?.serverId ?? null;
}, [pathname, daemons]);
const { client, isConnected } = useHostRuntimeSession(serverId ?? "");
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
);
@@ -25,7 +25,7 @@ import { navigateToWorkspace } from '@/hooks/use-workspace-navigation'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler'
import * as Clipboard from 'expo-clipboard'
import { Archive, ChevronDown, ChevronRight, Copy, MoreVertical, Plus } from 'lucide-react-native'
import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, Monitor, MoreVertical, Plus } from 'lucide-react-native'
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import type { DraggableListDragHandleProps } from './draggable-list.types'
@@ -160,18 +160,26 @@ function resolveStatusDotColor(input: {
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
loading = false,
}: {
bucket: SidebarWorkspaceEntry['statusBucket']
workspaceKind: SidebarWorkspaceEntry['workspaceKind']
loading?: boolean
}) {
const { theme } = useUnistyles()
const color = resolveStatusDotColor({ theme, bucket })
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done'
const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done'
if (shouldRenderIdlePlaceholder) {
return null
if (isIdle) {
const KindIcon = workspaceKind === 'local_checkout' ? Monitor : workspaceKind === 'worktree' ? FolderGit2 : null
if (!KindIcon) return null
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
</View>
)
}
return (
@@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : shouldShowSyncedLoader ? (
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
) : bucket === 'needs_input' ? (
<CircleHelp size={14} color={theme.colors.palette.amber[500]} />
) : bucket === 'attention' ? (
<Check size={14} color={theme.colors.palette.green[500]} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
@@ -213,7 +225,7 @@ function ProjectLeadingVisual({
{showChevron && chevron !== null ? (
<ProjectInlineChevron chevron={chevron} />
) : shouldShowWorkspaceStatus && activeWorkspace ? (
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} workspaceKind={activeWorkspace.workspaceKind} loading={isArchiving} />
) : iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
@@ -686,6 +698,7 @@ function WorkspaceRowInner({
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
@@ -1903,7 +1916,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
@@ -1938,7 +1951,7 @@ const styles = StyleSheet.create((theme) => ({
position: 'relative',
},
workspaceStatusDot: {
width: 11,
width: 14,
height: 16,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
@@ -72,6 +72,7 @@ interface SplitContainerProps {
newTabAgentOptionId?: "__new_tab_agent__";
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
onFocusPane: (paneId: string) => void;
@@ -751,6 +752,7 @@ function SplitPaneView({
activeTabDescriptor
? buildPaneContentModel({
paneId: pane.id,
isPaneFocused: isFocused,
tab: activeTabDescriptor,
})
: null,
@@ -19,7 +19,7 @@ import Svg, {
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import {
hasPendingTerminalModifiers,
@@ -150,7 +150,8 @@ export function TerminalPane({
});
const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(serverId);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);