feat: update sidebar agent workflow and host filter UI
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,16 +16,25 @@ import { SidebarAgentList } from "./sidebar-agent-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
buildHostAgentsRoute,
|
||||
buildHostSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
@@ -85,14 +94,23 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
sections,
|
||||
checkoutByAgentKey,
|
||||
entries,
|
||||
projectOptions,
|
||||
hasMoreEntries,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId });
|
||||
} = useSidebarAgentsGrouped({
|
||||
isOpen,
|
||||
serverId: activeServerId,
|
||||
selectedProjectKeys,
|
||||
});
|
||||
useEffect(() => {
|
||||
setSelectedProjectKeys([]);
|
||||
}, [activeServerId]);
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -102,7 +120,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
@@ -120,13 +137,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||
(s) => s.setSidebarShortcutAgentKeys
|
||||
);
|
||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
||||
}, [collapsedProjectKeys, sections]);
|
||||
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
||||
@@ -137,11 +153,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [closeToAgent]);
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
let targetServerId = activeServerId;
|
||||
let targetWorkingDir: string | null = null;
|
||||
|
||||
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||
pathname,
|
||||
selectedAgentId,
|
||||
});
|
||||
if (selectedAgent) {
|
||||
targetServerId = selectedAgent.serverId;
|
||||
const agent = useSessionStore
|
||||
.getState()
|
||||
.sessions[selectedAgent.serverId]
|
||||
?.agents?.get(selectedAgent.agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostAgentDraftRoute(activeServerId) as any);
|
||||
}, [activeServerId]);
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
}, [activeServerId, pathname, selectedAgentId]);
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||
@@ -198,6 +237,27 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
windowWidth,
|
||||
]);
|
||||
|
||||
const listFooterComponent = useMemo(() => {
|
||||
if (!hasMoreEntries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
|
||||
{({ hovered }) => (
|
||||
<Text
|
||||
style={[
|
||||
styles.listViewMoreButtonText,
|
||||
hovered && styles.listViewMoreButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
View more
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}, [handleViewMore, hasMoreEntries]);
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
(nextServerId: string) => {
|
||||
if (!nextServerId) {
|
||||
@@ -299,9 +359,36 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={styles.hostTrigger}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
@@ -316,10 +403,47 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={20}
|
||||
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}
|
||||
@@ -327,54 +451,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
sections={sections}
|
||||
checkoutByAgentKey={checkoutByAgentKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<Pressable
|
||||
style={styles.footerButton}
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
|
||||
All agents
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
@@ -389,11 +465,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
{/* Header: New Agent button - top padding area is draggable on Tauri */}
|
||||
<View
|
||||
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
@@ -403,13 +475,38 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={styles.hostTrigger}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
@@ -424,47 +521,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
sections={sections}
|
||||
checkoutByAgentKey={checkoutByAgentKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<Pressable
|
||||
style={styles.footerButton}
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
|
||||
All agents
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -480,6 +554,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
@@ -512,9 +597,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
sidebarHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[3],
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
},
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
userSelect: "none",
|
||||
@@ -522,7 +610,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newAgentButton: {
|
||||
@@ -545,12 +633,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
maxWidth: "55%",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
hostTriggerHovered: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
hostStatusDot: {
|
||||
width: 8,
|
||||
@@ -560,6 +654,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostTriggerText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
@@ -570,28 +666,43 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
footerHostSlot: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
footerButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
listViewMoreButton: {
|
||||
marginTop: theme.spacing[2],
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginBottom: theme.spacing[1],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
listViewMoreButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerButtonTextHovered: {
|
||||
listViewMoreButtonTextHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostPickerList: {
|
||||
|
||||
@@ -384,7 +384,6 @@ export default function TerminalEmulator({
|
||||
terminal.write(outputText);
|
||||
renderedOutputRef.current = outputText;
|
||||
}
|
||||
terminal.focus();
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose();
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Plus, RefreshCw } from "lucide-react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import Svg, {
|
||||
Defs,
|
||||
LinearGradient as SvgLinearGradient,
|
||||
Rect,
|
||||
Stop,
|
||||
} from "react-native-svg";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -23,6 +29,7 @@ interface TerminalPaneProps {
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -58,6 +65,29 @@ function terminalScopeKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd}`;
|
||||
}
|
||||
|
||||
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
||||
return (
|
||||
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient
|
||||
id={gradientId}
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="0%"
|
||||
>
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset="10%" stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
@@ -85,6 +115,62 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
||||
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
|
||||
useEffect(() => {
|
||||
selectedTerminalIdRef.current = selectedTerminalId;
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
const clearHoverOutTimeout = useCallback(() => {
|
||||
if (!hoverOutTimeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(hoverOutTimeoutRef.current);
|
||||
hoverOutTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTerminalTabHoverIn = useCallback(
|
||||
(terminalId: string) => {
|
||||
clearHoverOutTimeout();
|
||||
setHoveredTerminalId(terminalId);
|
||||
},
|
||||
[clearHoverOutTimeout]
|
||||
);
|
||||
|
||||
const handleTerminalTabHoverOut = useCallback(
|
||||
(terminalId: string) => {
|
||||
clearHoverOutTimeout();
|
||||
hoverOutTimeoutRef.current = setTimeout(() => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
setHoveredCloseTerminalId((current) =>
|
||||
current === terminalId ? null : current
|
||||
);
|
||||
}, 50);
|
||||
},
|
||||
[clearHoverOutTimeout]
|
||||
);
|
||||
|
||||
const handleTerminalCloseHoverIn = useCallback(
|
||||
(terminalId: string) => {
|
||||
clearHoverOutTimeout();
|
||||
setHoveredTerminalId(terminalId);
|
||||
setHoveredCloseTerminalId(terminalId);
|
||||
},
|
||||
[clearHoverOutTimeout]
|
||||
);
|
||||
|
||||
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
|
||||
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearHoverOutTimeout();
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: ["terminals", serverId, cwd] as const,
|
||||
@@ -100,6 +186,42 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
|
||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("terminal_stream_exit", (message) => {
|
||||
if (message.type !== "terminal_stream_exit") {
|
||||
return;
|
||||
}
|
||||
|
||||
const exitedTerminalId = message.payload.terminalId;
|
||||
if (!exitedTerminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTerminalIdRef.current === exitedTerminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === exitedTerminalId ? null : current
|
||||
);
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === exitedTerminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
@@ -118,6 +240,39 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const killTerminalMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: (_, terminalId) => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
if (selectedTerminalIdRef.current === terminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === terminalId ? null : current
|
||||
);
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === terminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
type: "active",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
|
||||
lastReportedSizeRef.current = null;
|
||||
@@ -263,14 +418,23 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
||||
: "";
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void terminalsQuery.refetch();
|
||||
}, [terminalsQuery]);
|
||||
|
||||
const handleCreateTerminal = useCallback(() => {
|
||||
createTerminalMutation.mutate();
|
||||
}, [createTerminalMutation]);
|
||||
|
||||
const handleCloseTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (
|
||||
killTerminalMutation.isPending &&
|
||||
killTerminalMutation.variables === terminalId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
killTerminalMutation.mutate(terminalId);
|
||||
},
|
||||
[killTerminalMutation]
|
||||
);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
@@ -430,12 +594,15 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
const queryError =
|
||||
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
|
||||
const isCreating = createTerminalMutation.isPending;
|
||||
const isRefreshing = terminalsQuery.isFetching;
|
||||
const createError =
|
||||
createTerminalMutation.error instanceof Error
|
||||
? createTerminalMutation.error.message
|
||||
: null;
|
||||
const combinedError = streamError ?? createError ?? queryError;
|
||||
const closeError =
|
||||
killTerminalMutation.error instanceof Error
|
||||
? killTerminalMutation.error.message
|
||||
: null;
|
||||
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -448,40 +615,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
>
|
||||
{terminals.map((terminal) => {
|
||||
const isActive = terminal.id === selectedTerminalId;
|
||||
const isTabHovered = hoveredTerminalId === terminal.id;
|
||||
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
|
||||
const isClosingTerminal =
|
||||
killTerminalMutation.isPending &&
|
||||
killTerminalMutation.variables === terminal.id;
|
||||
const shouldShowCloseButton =
|
||||
isTabHovered || isCloseHovered || isClosingTerminal;
|
||||
const gradientId = `terminal-close-gradient-${terminal.id.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-"
|
||||
)}`;
|
||||
return (
|
||||
<Pressable
|
||||
key={terminal.id}
|
||||
testID={`terminal-tab-${terminal.id}`}
|
||||
onPress={() => setSelectedTerminalId(terminal.id)}
|
||||
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
|
||||
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.terminalTab,
|
||||
isActive && styles.terminalTabActive,
|
||||
shouldShowCloseButton && styles.terminalTabHovered,
|
||||
(pressed || hovered) && styles.terminalTabHovered,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}>
|
||||
<Text
|
||||
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{terminal.name}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID={`terminal-close-${terminal.id}`}
|
||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||
disabled={!shouldShowCloseButton || isClosingTerminal}
|
||||
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
|
||||
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCloseTerminal(terminal.id);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.terminalTabCloseButton,
|
||||
shouldShowCloseButton
|
||||
? styles.terminalTabCloseButtonShown
|
||||
: styles.terminalTabCloseButtonHidden,
|
||||
]}
|
||||
>
|
||||
{({ hovered = false, pressed = false }) => {
|
||||
const iconColor =
|
||||
hovered || pressed
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
<TerminalCloseGradient
|
||||
color={theme.colors.surface2}
|
||||
gradientId={gradientId}
|
||||
/>
|
||||
<View style={styles.terminalTabCloseIcon}>
|
||||
{isClosingTerminal ? (
|
||||
<ActivityIndicator size={12} color={iconColor} />
|
||||
) : (
|
||||
<X size={12} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
testID="terminals-refresh-button"
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.headerIconButton,
|
||||
(hovered || pressed) && styles.headerIconButtonHovered,
|
||||
]}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RefreshCw size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="terminals-create-button"
|
||||
onPress={handleCreateTerminal}
|
||||
@@ -605,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
tabsScroll: {
|
||||
flex: 1,
|
||||
@@ -620,27 +829,59 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
},
|
||||
terminalTab: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
justifyContent: "center",
|
||||
maxWidth: TERMINAL_TAB_MAX_WIDTH,
|
||||
minWidth: 96,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
terminalTabHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
terminalTabActive: {
|
||||
borderColor: theme.colors.primary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
terminalTabText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
terminalTabTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
terminalTabCloseButton: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 32,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
paddingRight: theme.spacing[2],
|
||||
},
|
||||
terminalTabCloseButtonShown: {
|
||||
opacity: 1,
|
||||
},
|
||||
terminalTabCloseButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
terminalTabCloseGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
overflow: "hidden",
|
||||
zIndex: 0,
|
||||
},
|
||||
terminalTabCloseIcon: {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ComboboxProps {
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
@@ -45,6 +46,7 @@ export interface ComboboxProps {
|
||||
title?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -158,6 +160,7 @@ export function Combobox({
|
||||
value,
|
||||
onSelect,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
placeholder = "Search...",
|
||||
searchPlaceholder,
|
||||
emptyText = "No options match your search.",
|
||||
@@ -167,6 +170,7 @@ export function Combobox({
|
||||
title = "Select",
|
||||
open,
|
||||
onOpenChange,
|
||||
desktopPlacement = "top-start",
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
@@ -245,7 +249,7 @@ export function Combobox({
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: Platform.OS === "web" ? "top-start" : "bottom-start",
|
||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
@@ -261,7 +265,7 @@ export function Combobox({
|
||||
}
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isMobile, update, isOpen]);
|
||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -293,7 +297,7 @@ export function Combobox({
|
||||
[]
|
||||
);
|
||||
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return options;
|
||||
@@ -308,6 +312,7 @@ export function Combobox({
|
||||
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption =
|
||||
searchable &&
|
||||
allowCustomValue &&
|
||||
sanitizedSearchValue.length > 0 &&
|
||||
!options.some(
|
||||
@@ -463,7 +468,7 @@ export function Combobox({
|
||||
|
||||
const content = children ?? (
|
||||
<>
|
||||
{searchInput}
|
||||
{searchable ? searchInput : null}
|
||||
{optionsList}
|
||||
</>
|
||||
);
|
||||
@@ -537,7 +542,7 @@ export function Combobox({
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{searchInput}
|
||||
{searchable ? searchInput : null}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -581,7 +586,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: 0,
|
||||
...(IS_WEB
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -605,6 +605,10 @@ export function SessionProvider({
|
||||
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, agent);
|
||||
return next;
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
const pendingPermissions = new Map();
|
||||
const agentLastActivity = new Map();
|
||||
|
||||
for (const snapshot of agentsList) {
|
||||
for (const { agent: snapshot } of agentsList.entries) {
|
||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
||||
agents.set(agent.id, agent);
|
||||
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
||||
|
||||
@@ -56,7 +56,9 @@ export function useAllAgentsList(options?: {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents();
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
@@ -76,11 +78,12 @@ export function useAllAgentsList(options?: {
|
||||
if (!serverId) {
|
||||
return [];
|
||||
}
|
||||
const data = agentsQuery.data ?? [];
|
||||
const data = agentsQuery.data?.entries ?? [];
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (const snapshot of data) {
|
||||
for (const entry of data) {
|
||||
const snapshot = entry.agent;
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
const live = liveAgents?.get(snapshot.id);
|
||||
list.push(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
@@ -95,21 +96,23 @@ export function useKeyboardShortcuts({
|
||||
const navigateToNewAgent = (): boolean => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
let targetWorkingDir: string | null = null;
|
||||
if (selectedAgentId) {
|
||||
const separatorIndex = selectedAgentId.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
||||
targetServerId = serverId;
|
||||
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||
pathname,
|
||||
selectedAgentId,
|
||||
});
|
||||
if (selectedAgent) {
|
||||
targetServerId = selectedAgent.serverId;
|
||||
const agent = useSessionStore
|
||||
.getState()
|
||||
.sessions[selectedAgent.serverId]
|
||||
?.agents?.get(selectedAgent.agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
useSectionOrderStore,
|
||||
sortProjectsByStoredOrder,
|
||||
} from "@/stores/section-order-store";
|
||||
import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages";
|
||||
deriveSidebarStateBucket,
|
||||
isSidebarActiveAgent,
|
||||
} from "@/utils/sidebar-agent-state";
|
||||
import type { ProjectPlacementPayload } from "@server/shared/messages";
|
||||
|
||||
const SIDEBAR_GROUPS_STALE_TIME = 15_000;
|
||||
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
|
||||
const MAX_AGENTS_PER_PROJECT = 5;
|
||||
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
|
||||
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
|
||||
const SIDEBAR_DONE_FILL_TARGET = 50;
|
||||
|
||||
type SidebarGroupsPayload =
|
||||
FetchAgentsGroupedByProjectResponseMessage["payload"];
|
||||
export type SidebarCheckoutLite =
|
||||
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
|
||||
type MutableSidebarGroup = {
|
||||
export interface SidebarProjectOption {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
agents: AggregatedAgent[];
|
||||
};
|
||||
activeCount: number;
|
||||
totalCount: number;
|
||||
serverId: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export interface SidebarSectionData {
|
||||
key: string;
|
||||
projectKey: string;
|
||||
title: string;
|
||||
agents: AggregatedAgent[];
|
||||
firstAgentServerId?: string;
|
||||
firstAgentId?: string;
|
||||
workingDir?: string;
|
||||
export interface SidebarAgentListEntry {
|
||||
agent: AggregatedAgent & { createdAt: Date };
|
||||
project: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
export interface SidebarAgentsGroupedResult {
|
||||
sections: SidebarSectionData[];
|
||||
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
|
||||
entries: SidebarAgentListEntry[];
|
||||
projectOptions: SidebarProjectOption[];
|
||||
hasMoreEntries: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
function compareByLastActivityDesc(
|
||||
left: SidebarAgentListEntry,
|
||||
right: SidebarAgentListEntry
|
||||
): number {
|
||||
return right.agent.lastActivityAt.getTime() - left.agent.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
function compareByTitleAsc(
|
||||
left: SidebarAgentListEntry,
|
||||
right: SidebarAgentListEntry
|
||||
): number {
|
||||
const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase();
|
||||
const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase();
|
||||
const titleCmp = leftTitle.localeCompare(rightTitle, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (titleCmp !== 0) {
|
||||
return titleCmp;
|
||||
}
|
||||
|
||||
// Deterministic tie-breaker so running rows stay stable while status updates stream.
|
||||
return left.agent.id.localeCompare(right.agent.id, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function applySidebarDefaultOrdering(
|
||||
entries: SidebarAgentListEntry[]
|
||||
): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
|
||||
const needsInput: SidebarAgentListEntry[] = [];
|
||||
const failed: SidebarAgentListEntry[] = [];
|
||||
const running: SidebarAgentListEntry[] = [];
|
||||
const attention: SidebarAgentListEntry[] = [];
|
||||
const done: SidebarAgentListEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const bucket = deriveSidebarStateBucket({
|
||||
status: entry.agent.status,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
if (bucket === "needs_input") {
|
||||
needsInput.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (bucket === "failed") {
|
||||
failed.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (bucket === "running") {
|
||||
running.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (bucket === "attention") {
|
||||
attention.push(entry);
|
||||
continue;
|
||||
}
|
||||
done.push(entry);
|
||||
}
|
||||
|
||||
needsInput.sort(compareByLastActivityDesc);
|
||||
failed.sort(compareByLastActivityDesc);
|
||||
running.sort(compareByTitleAsc);
|
||||
attention.sort(compareByLastActivityDesc);
|
||||
done.sort(compareByLastActivityDesc);
|
||||
|
||||
const active = [...needsInput, ...failed, ...running, ...attention];
|
||||
if (active.length >= SIDEBAR_DONE_FILL_TARGET) {
|
||||
return { entries: active, hasMore: done.length > 0 };
|
||||
}
|
||||
|
||||
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
|
||||
const shownDone = done.slice(0, remainingDoneSlots);
|
||||
return {
|
||||
entries: [...active, ...shownDone],
|
||||
hasMore: done.length > shownDone.length,
|
||||
};
|
||||
}
|
||||
|
||||
function toAggregatedAgent(params: {
|
||||
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
}): AggregatedAgent {
|
||||
}): AggregatedAgent & { createdAt: Date } {
|
||||
const source = params.source;
|
||||
return {
|
||||
id: source.id,
|
||||
@@ -55,6 +131,7 @@ function toAggregatedAgent(params: {
|
||||
serverLabel: params.serverLabel,
|
||||
title: source.title ?? null,
|
||||
status: source.status,
|
||||
createdAt: source.createdAt,
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
provider: source.provider,
|
||||
@@ -69,6 +146,7 @@ function toAggregatedAgent(params: {
|
||||
export function useSidebarAgentsGrouped(options?: {
|
||||
isOpen?: boolean;
|
||||
serverId?: string | null;
|
||||
selectedProjectKeys?: string[];
|
||||
}): SidebarAgentsGroupedResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -79,6 +157,15 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
const selectedProjectKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(options?.selectedProjectKeys ?? [])
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
),
|
||||
[options?.selectedProjectKeys]
|
||||
);
|
||||
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
@@ -88,189 +175,160 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const groupedQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsList", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgentsGroupedByProject({
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
sort: [
|
||||
{ key: "status_priority", direction: "asc" },
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
],
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
|
||||
staleTime: SIDEBAR_AGENTS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
|
||||
refetchIntervalInBackground: isOpen,
|
||||
refetchOnMount: "always" as const,
|
||||
});
|
||||
|
||||
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
|
||||
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||
if (!serverId) {
|
||||
return {
|
||||
sections: [] as SidebarSectionData[],
|
||||
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
|
||||
entries: [] as SidebarAgentListEntry[],
|
||||
projectOptions: [] as SidebarProjectOption[],
|
||||
hasAnyData: false,
|
||||
hasMoreEntries: false,
|
||||
};
|
||||
}
|
||||
|
||||
const groupsByKey = new Map<string, MutableSidebarGroup>();
|
||||
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
|
||||
const seenAgentKeys = new Set<string>();
|
||||
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
|
||||
const groupedFetchReady = groupedQuery.isFetched;
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const seenAgentIds = new Set<string>();
|
||||
const byProject = new Map<string, SidebarProjectOption>();
|
||||
const mergedEntries: SidebarAgentListEntry[] = [];
|
||||
|
||||
if (payload) {
|
||||
for (const group of payload.groups) {
|
||||
const existing: MutableSidebarGroup =
|
||||
groupsByKey.get(group.projectKey) ??
|
||||
{
|
||||
projectKey: group.projectKey,
|
||||
projectName: group.projectName,
|
||||
agents: [],
|
||||
};
|
||||
|
||||
for (const entry of group.agents) {
|
||||
const normalized = normalizeAgentSnapshot(entry.agent, serverId);
|
||||
const live = liveAgents?.get(entry.agent.id);
|
||||
const nextAgent = toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
if (nextAgent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentKey = `${serverId}:${entry.agent.id}`;
|
||||
seenAgentKeys.add(agentKey);
|
||||
checkoutLookup.set(
|
||||
agentKey,
|
||||
live?.projectPlacement?.checkout ?? entry.checkout
|
||||
);
|
||||
existing.agents.push(nextAgent);
|
||||
}
|
||||
|
||||
groupsByKey.set(group.projectKey, existing);
|
||||
const pushEntry = (entry: SidebarAgentListEntry): void => {
|
||||
if (entry.agent.archivedAt) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = `${entry.agent.serverId}:${entry.agent.id}`;
|
||||
if (seenAgentIds.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seenAgentIds.add(dedupeKey);
|
||||
mergedEntries.push(entry);
|
||||
|
||||
const existing = byProject.get(entry.project.projectKey);
|
||||
const isActive = isSidebarActiveAgent({
|
||||
status: entry.agent.status,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
if (existing) {
|
||||
existing.totalCount += 1;
|
||||
if (isActive) {
|
||||
existing.activeCount += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
byProject.set(entry.project.projectKey, {
|
||||
projectKey: entry.project.projectKey,
|
||||
projectName: entry.project.projectName,
|
||||
activeCount: isActive ? 1 : 0,
|
||||
totalCount: 1,
|
||||
serverId,
|
||||
workingDir: entry.project.checkout.cwd,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchedEntries = agentsQuery.data?.entries ?? [];
|
||||
for (const fetchedEntry of fetchedEntries) {
|
||||
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
|
||||
const live = liveAgents?.get(fetchedEntry.agent.id);
|
||||
const project = live?.projectPlacement ?? fetchedEntry.project;
|
||||
if (!project) {
|
||||
continue;
|
||||
}
|
||||
const agent = toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
pushEntry({ agent, project });
|
||||
}
|
||||
|
||||
if (groupedFetchReady && liveAgents) {
|
||||
if (liveAgents) {
|
||||
for (const live of liveAgents.values()) {
|
||||
if (live.archivedAt || live.labels.ui !== "true") {
|
||||
continue;
|
||||
}
|
||||
if (!live.projectPlacement) {
|
||||
// Ignore fetchAgents-hydrated snapshots for sidebar placement.
|
||||
// Sidebar should derive placement from grouped RPC or project-enriched agent_update.
|
||||
continue;
|
||||
}
|
||||
const agentKey = `${serverId}:${live.id}`;
|
||||
if (seenAgentKeys.has(agentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const livePlacement = live.projectPlacement;
|
||||
const projectKey = livePlacement.projectKey;
|
||||
const existing: MutableSidebarGroup =
|
||||
groupsByKey.get(projectKey) ??
|
||||
{
|
||||
projectKey,
|
||||
projectName: livePlacement.projectName,
|
||||
agents: [],
|
||||
};
|
||||
existing.agents.push(
|
||||
toAggregatedAgent({
|
||||
source: live,
|
||||
serverId,
|
||||
serverLabel,
|
||||
})
|
||||
);
|
||||
checkoutLookup.set(agentKey, livePlacement.checkout);
|
||||
groupsByKey.set(projectKey, existing);
|
||||
const agent = toAggregatedAgent({
|
||||
source: live,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
pushEntry({ agent, project: live.projectPlacement });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(groupsByKey.values())
|
||||
.map((group) => {
|
||||
const agents = [...group.agents].sort(
|
||||
(left, right) =>
|
||||
right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||
);
|
||||
return {
|
||||
...group,
|
||||
agents: agents.slice(0, MAX_AGENTS_PER_PROJECT),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.agents.length > 0)
|
||||
.sort((left, right) => {
|
||||
const leftRecent = left.agents[0]?.lastActivityAt.getTime() ?? 0;
|
||||
const rightRecent = right.agents[0]?.lastActivityAt.getTime() ?? 0;
|
||||
return rightRecent - leftRecent;
|
||||
});
|
||||
const filteredEntries =
|
||||
selectedProjectKeys.size > 0
|
||||
? mergedEntries.filter((entry) =>
|
||||
selectedProjectKeys.has(entry.project.projectKey)
|
||||
)
|
||||
: mergedEntries;
|
||||
|
||||
const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder);
|
||||
const nextSections = orderedGroups.map((group) => {
|
||||
const firstAgent = group.agents[0];
|
||||
return {
|
||||
key: `project:${group.projectKey}`,
|
||||
projectKey: group.projectKey,
|
||||
title: group.projectName,
|
||||
agents: group.agents,
|
||||
firstAgentServerId: firstAgent?.serverId,
|
||||
firstAgentId: firstAgent?.id,
|
||||
workingDir: firstAgent?.cwd,
|
||||
};
|
||||
const ordered = applySidebarDefaultOrdering(filteredEntries);
|
||||
const options = Array.from(byProject.values()).sort((left, right) => {
|
||||
if (left.activeCount !== right.activeCount) {
|
||||
return right.activeCount - left.activeCount;
|
||||
}
|
||||
return left.projectName.localeCompare(right.projectName);
|
||||
});
|
||||
|
||||
return {
|
||||
sections: nextSections,
|
||||
checkoutByAgentKey: checkoutLookup,
|
||||
hasAnyData: nextSections.length > 0,
|
||||
entries: ordered.entries,
|
||||
projectOptions: options,
|
||||
hasAnyData: ordered.entries.length > 0,
|
||||
hasMoreEntries: ordered.hasMore,
|
||||
};
|
||||
}, [
|
||||
agentsQuery.data?.entries,
|
||||
connectionStates,
|
||||
groupedQuery.data,
|
||||
groupedQuery.isFetched,
|
||||
liveAgents,
|
||||
projectOrder,
|
||||
selectedProjectKeys,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentKeys = sections.map((section) => section.projectKey);
|
||||
const storedKeys = new Set(projectOrder);
|
||||
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
||||
if (newKeys.length > 0) {
|
||||
setProjectOrder([...projectOrder, ...newKeys]);
|
||||
}
|
||||
}, [sections, projectOrder, setProjectOrder]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId],
|
||||
queryKey: ["sidebarAgentsList", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
|
||||
const isFetching =
|
||||
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && !hasAnyData;
|
||||
const isRevalidating = isFetching && hasAnyData;
|
||||
|
||||
return {
|
||||
sections,
|
||||
checkoutByAgentKey,
|
||||
entries,
|
||||
projectOptions,
|
||||
hasMoreEntries,
|
||||
isLoading: isFetching,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.4,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
@@ -180,8 +179,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
hostCardPressed: {
|
||||
opacity: 0.85,
|
||||
hostSettingsButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "transparent",
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
hostSettingsButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
advancedTrigger: {
|
||||
flexDirection: "row",
|
||||
@@ -646,7 +656,7 @@ export default function SettingsScreen() {
|
||||
connectionStatus={connectionStatus}
|
||||
activeConnection={activeConnection}
|
||||
lastError={lastConnectionError}
|
||||
onPress={handleEditDaemon}
|
||||
onOpenSettings={handleEditDaemon}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -885,8 +895,6 @@ function HostDetailModal({
|
||||
}: HostDetailModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [draftLabel, setDraftLabel] = useState("");
|
||||
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
|
||||
const activeServerIdRef = useRef<string | null>(null);
|
||||
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||
|
||||
@@ -1033,28 +1041,18 @@ function HostDetailModal({
|
||||
|
||||
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
||||
setDraftLabel(nextValue);
|
||||
setIsDraftLabelDirty(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !host) return;
|
||||
const hostChanged = activeServerIdRef.current !== host.serverId;
|
||||
if (hostChanged) {
|
||||
setDraftLabel(host.label ?? "");
|
||||
setIsDraftLabelDirty(false);
|
||||
activeServerIdRef.current = host.serverId;
|
||||
return;
|
||||
}
|
||||
if (!isDraftLabelDirty) {
|
||||
setDraftLabel(host.label ?? "");
|
||||
}
|
||||
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
|
||||
// Initialize once per modal open / host switch; keep user edits fully local while typing.
|
||||
setDraftLabel(host.label ?? "");
|
||||
}, [visible, host?.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
activeServerIdRef.current = null;
|
||||
setIsRestarting(false);
|
||||
setIsDraftLabelDirty(false);
|
||||
setDraftLabel("");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -1304,7 +1302,7 @@ interface DaemonCardProps {
|
||||
connectionStatus: ConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
onPress: (daemon: HostProfile) => void;
|
||||
onOpenSettings: (daemon: HostProfile) => void;
|
||||
}
|
||||
|
||||
function DaemonCard({
|
||||
@@ -1312,7 +1310,7 @@ function DaemonCard({
|
||||
connectionStatus,
|
||||
activeConnection,
|
||||
lastError,
|
||||
onPress,
|
||||
onOpenSettings,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
@@ -1347,12 +1345,9 @@ function DaemonCard({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() => onPress(daemon)}
|
||||
<View
|
||||
style={styles.hostCard}
|
||||
testID={`daemon-card-${daemon.serverId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${daemon.label}, ${statusLabel}`}
|
||||
>
|
||||
<View style={styles.hostCardContent}>
|
||||
<View style={styles.hostHeaderRow}>
|
||||
@@ -1375,10 +1370,28 @@ function DaemonCard({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.hostSettingsButton,
|
||||
(pressed || hovered) && styles.hostSettingsButtonActive,
|
||||
]}
|
||||
onPress={() => onOpenSettings(daemon)}
|
||||
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Settings
|
||||
size={16}
|
||||
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
parseAgentKey,
|
||||
resolveNewAgentWorkingDir,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
} from "./new-agent-routing";
|
||||
|
||||
describe("buildNewAgentRoute", () => {
|
||||
@@ -35,3 +37,60 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAgentKey", () => {
|
||||
it("parses server and agent ids from combined key", () => {
|
||||
expect(parseAgentKey("srv-1:agent-9")).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-9",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the last separator to preserve server ids with colons", () => {
|
||||
expect(parseAgentKey("localhost:6767:agent-9")).toEqual({
|
||||
serverId: "localhost:6767",
|
||||
agentId: "agent-9",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed keys", () => {
|
||||
expect(parseAgentKey("")).toBeNull();
|
||||
expect(parseAgentKey("only-server")).toBeNull();
|
||||
expect(parseAgentKey(":agent-1")).toBeNull();
|
||||
expect(parseAgentKey("srv-1:")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSelectedAgentForNewAgent", () => {
|
||||
it("prefers the agent in the current route", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/agent/agent-2",
|
||||
selectedAgentId: "srv-9:agent-9",
|
||||
})
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to selected agent key when route has no agent", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/settings",
|
||||
selectedAgentId: "srv-1:agent-7",
|
||||
})
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-7",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when neither route nor selection has an agent", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/settings",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function parseAgentKey(
|
||||
key: string | null | undefined
|
||||
): { serverId: string; agentId: string } | null {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const sep = key.lastIndexOf(":");
|
||||
if (sep <= 0 || sep >= key.length - 1) {
|
||||
return null;
|
||||
}
|
||||
const serverId = key.slice(0, sep).trim();
|
||||
const agentId = key.slice(sep + 1).trim();
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function resolveSelectedAgentForNewAgent(input: {
|
||||
pathname: string;
|
||||
selectedAgentId?: string;
|
||||
}): { serverId: string; agentId: string } | null {
|
||||
return (
|
||||
parseHostAgentRouteFromPathname(input.pathname) ??
|
||||
parseAgentKey(input.selectedAgentId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNewAgentWorkingDir(
|
||||
cwd: string,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
|
||||
export type SidebarAttentionReason =
|
||||
| "finished"
|
||||
| "error"
|
||||
| "permission"
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SidebarStateBucket =
|
||||
| "needs_input"
|
||||
| "failed"
|
||||
| "running"
|
||||
| "attention"
|
||||
| "done";
|
||||
|
||||
export function deriveSidebarStateBucket(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): SidebarStateBucket {
|
||||
if (input.requiresAttention && input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
// Unread/attention-needed completed agents are active in sidebar logic.
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
}
|
||||
|
||||
export function isSidebarActiveAgent(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): boolean {
|
||||
return deriveSidebarStateBucket(input) !== "done";
|
||||
}
|
||||
Reference in New Issue
Block a user