Update files

This commit is contained in:
Mohamed Boudra
2026-02-13 05:08:26 +00:00
parent b64fa4ca93
commit 4948ba581a
33 changed files with 764 additions and 565 deletions
+6 -2
View File
@@ -28,6 +28,10 @@ import {
buildAgentNavigationKey,
startNavigationTiming,
} from "@/utils/navigation-timing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -114,12 +118,12 @@ export function AgentList({
params: { serverId, agentId },
});
const shouldReplace = pathname.startsWith("/agent/");
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.();
navigate(`/agent/${serverId}/${agentId}` as any);
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);
@@ -1,6 +1,6 @@
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic } from "lucide-react-native";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
@@ -233,7 +233,7 @@ export function DictationOverlay({
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
>
<Check
<Pencil
size={20}
color={theme.colors.palette.white}
strokeWidth={2.5}
@@ -49,6 +49,7 @@ import {
type ActionStatus,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
// =============================================================================
// Git Actions Data Structure
@@ -800,7 +801,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
setActionError(null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace("/agent" as any);
router.replace(buildHostAgentDraftRoute(serverId) as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
@@ -3,13 +3,11 @@ import {
useDaemonRegistry,
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints";
import { probeConnection } from "@/utils/test-daemon-connection";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
type Candidate = {
@@ -67,120 +65,17 @@ function buildCandidates(host: HostProfile): Candidate[] {
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateHost } = useDaemonRegistry();
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
const active = candidates[activeIndex] ?? candidates[0] ?? null;
const active =
candidates.find((candidate) => candidate.connectionId === daemon.preferredConnectionId) ??
candidates[0] ??
null;
const activeUrl = active?.url ?? null;
const lastAttemptedUrlRef = useRef<string | null>(null);
const pendingPreferenceWriteRef = useRef(false);
const upgradeProbeInFlightRef = useRef(false);
useEffect(() => {
if (!activeUrl) {
return;
}
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.findIndex((c) => c.url === activeUrl);
if (idx === -1) {
setActiveIndex(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.map((c) => c.url).join("|")]);
if (!activeUrl) {
return null;
}
const connection = connectionStates.get(daemon.serverId);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (!active) return;
if (pendingPreferenceWriteRef.current) return;
if (daemon.preferredConnectionId === active.connectionId) return;
pendingPreferenceWriteRef.current = true;
void updateHost(daemon.serverId, {
preferredConnectionId: active.connectionId,
}).finally(() => {
pendingPreferenceWriteRef.current = false;
});
return;
}
if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) {
if (lastAttemptedUrlRef.current === activeUrl) {
return;
}
lastAttemptedUrlRef.current = activeUrl;
if (activeIndex < candidates.length - 1) {
setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1));
}
}
}, [
active,
activeIndex,
activeUrl,
candidates.length,
daemon.preferredConnectionId,
daemon.serverId,
lastError,
status,
updateHost,
connection,
]);
useEffect(() => {
if (!connection) return;
if (status !== "online") return;
if (activeIndex === 0) return;
const best = candidates[0];
if (!best) return;
if (best.activeConnection.type !== "direct") return;
const intervalMs = 15_000;
let cancelled = false;
const attemptUpgrade = async () => {
if (cancelled) return;
if (upgradeProbeInFlightRef.current) return;
if (activeIndex === 0) return;
upgradeProbeInFlightRef.current = true;
try {
const { serverId } = await probeConnection(
{ id: "probe", type: "direct", endpoint: best.activeConnection.endpoint },
{ timeoutMs: 2000 },
);
if (cancelled) return;
if (serverId !== daemon.serverId) return;
lastAttemptedUrlRef.current = null;
setActiveIndex(0);
} catch {
// ignore - we'll retry periodically
} finally {
upgradeProbeInFlightRef.current = false;
}
};
void attemptUpgrade();
const interval = setInterval(() => void attemptUpgrade(), intervalMs);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [activeIndex, candidates, connection, daemon.serverId, status]);
return (
<SessionProvider
key={daemon.serverId}
@@ -31,6 +31,11 @@ import {
buildAgentNavigationKey,
startNavigationTiming,
} from "@/utils/navigation-timing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
import {
useSectionOrderStore,
} from "@/stores/section-order-store";
@@ -63,7 +68,7 @@ interface SectionHeaderProps {
checkout: SidebarCheckoutLite | null;
isCollapsed: boolean;
onToggle: () => void;
onCreateAgent: (workingDir: string) => void;
onCreateAgent: (serverId: string, workingDir: string) => void;
onDrag: () => void;
isDragging: boolean;
}
@@ -109,11 +114,12 @@ function SectionHeader({
const handleCreatePress = useCallback(
(e: { stopPropagation: () => void }) => {
e.stopPropagation();
if (createAgentWorkingDir) {
onCreateAgent(createAgentWorkingDir);
const serverId = section.firstAgentServerId;
if (createAgentWorkingDir && serverId) {
onCreateAgent(serverId, createAgentWorkingDir);
}
},
[onCreateAgent, createAgentWorkingDir]
[onCreateAgent, createAgentWorkingDir, section.firstAgentServerId]
);
return (
@@ -405,12 +411,12 @@ export function SidebarAgentList({
params: { serverId, agentId },
});
const shouldReplace = pathname.startsWith("/agent/");
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.();
navigate(`/agent/${serverId}/${agentId}` as any);
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);
@@ -432,9 +438,9 @@ export function SidebarAgentList({
}, [actionAgent, actionClient]);
const handleCreateAgentInProject = useCallback(
(workingDir: string) => {
(serverId: string, workingDir: string) => {
onAgentSelect?.();
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
router.push(buildNewAgentRoute(serverId, workingDir) as any);
},
[onAgentSelect]
);
+195 -35
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState, useEffect } from "react";
import { useCallback, useMemo, useState, useEffect, useRef } from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
@@ -10,7 +10,7 @@ import Animated, {
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Plus, Settings, Users } from "lucide-react-native";
import { router } from "expo-router";
import { router, usePathname } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -20,6 +20,17 @@ import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-wind
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-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 { formatConnectionStatus } from "@/utils/daemons";
import {
buildHostAgentDraftRoute,
buildHostAgentsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes";
const DESKTOP_SIDEBAR_WIDTH = 320;
@@ -35,6 +46,42 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const { daemons } = useDaemonRegistry();
const { connectionStates } = useDaemonConnections();
const activeServerIdFromPath = useMemo(
() => parseServerIdFromPathname(pathname),
[pathname]
);
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeServerId) return "No host";
const daemon = daemons.find((entry) => entry.serverId === activeServerId);
const trimmed = daemon?.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeServerId;
}, [activeServerId, daemons]);
const activeHostStatus = activeServerId
? (connectionStates.get(activeServerId)?.status ?? "idle")
: "idle";
const activeHostStatusColor =
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
connectionStates.get(daemon.serverId)?.status ?? "idle"
),
})),
[connectionStates, daemons]
);
const hostTriggerRef = useRef<View>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
@@ -45,7 +92,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({ isOpen });
} = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId });
const {
translateX,
backdropOpacity,
@@ -88,8 +135,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent]);
const handleCreateAgentClean = useCallback(() => {
router.push("/agent" as any);
}, []);
if (!activeServerId) {
return;
}
router.push(buildHostAgentDraftRoute(activeServerId) as any);
}, [activeServerId]);
// Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => {
@@ -104,14 +154,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return;
}
closeToAgent();
router.push("/settings");
}, [closeToAgent]);
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
// Desktop: just navigate, don't close
const handleSettingsDesktop = useCallback(() => {
router.push("/settings");
}, []);
if (!activeServerId) {
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
// Mobile: close sidebar when agent is selected
// Snap immediately since navigation interrupts animations
@@ -122,13 +178,35 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent, translateX, backdropOpacity, windowWidth]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return;
}
if (isMobile) {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
}
router.push("/agents");
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
router.push(buildHostAgentsRoute(activeServerId) as any);
}, [
activeServerId,
backdropOpacity,
closeToAgent,
isMobile,
translateX,
windowWidth,
]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
},
[pathname]
);
// Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement
@@ -206,18 +284,46 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header */}
<View style={styles.sidebarHeader}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanMobile}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanMobile}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</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 */}
@@ -286,18 +392,46 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
{...dragHandlers}
>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanDesktop}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleCreateAgentCleanDesktop}
>
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</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 */}
@@ -383,12 +517,19 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
flexShrink: 0,
},
newAgentButtonHovered: {},
newAgentButtonText: {
@@ -399,6 +540,25 @@ const styles = StyleSheet.create((theme) => ({
newAgentButtonTextHovered: {
color: theme.colors.foreground,
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[2],
minWidth: 0,
maxWidth: "55%",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
@@ -9,6 +9,7 @@ import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { NameHostModal } from "./name-host-modal";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -90,7 +91,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace({ pathname: "/", params: { serverId } });
router.replace(buildHostAgentDraftRoute(serverId) as any);
},
[router]
);