Update files
This commit is contained in:
@@ -43,6 +43,10 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -268,53 +272,8 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const isLoading = settingsLoading || registryLoading;
|
||||
|
||||
const isInitialConnectionPending = useMemo(() => {
|
||||
if (daemons.length === 0) return false;
|
||||
return daemons.some((daemon) => {
|
||||
const record = connectionStates.get(daemon.serverId);
|
||||
if (!record) return true;
|
||||
if (record.hasEverReceivedAgentList) return false;
|
||||
return (
|
||||
record.status === "idle" ||
|
||||
record.status === "connecting" ||
|
||||
(record.status === "online" && !record.agentListReady)
|
||||
);
|
||||
});
|
||||
}, [daemons, connectionStates]);
|
||||
|
||||
const [connectionTimedOut, setConnectionTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialConnectionPending) {
|
||||
setConnectionTimedOut(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setConnectionTimedOut(true), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInitialConnectionPending]);
|
||||
|
||||
const connectingMessage = useMemo(() => {
|
||||
if (!isInitialConnectionPending) return null;
|
||||
const pending = daemons.filter((daemon) => {
|
||||
const record = connectionStates.get(daemon.serverId);
|
||||
if (!record) return true;
|
||||
if (record.hasEverReceivedAgentList) return false;
|
||||
return (
|
||||
record.status === "idle" ||
|
||||
record.status === "connecting" ||
|
||||
(record.status === "online" && !record.agentListReady)
|
||||
);
|
||||
});
|
||||
if (pending.length === 1) {
|
||||
const label = pending[0].label?.trim();
|
||||
return label ? `Connecting to ${label}...` : "Connecting...";
|
||||
}
|
||||
return "Connecting...";
|
||||
}, [isInitialConnectionPending, daemons, connectionStates]);
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
@@ -330,10 +289,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (connectingMessage && !connectionTimedOut) {
|
||||
return <LoadingView message={connectingMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
|
||||
@@ -359,7 +314,7 @@ function OfferLinkListener({
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as any)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace({ pathname: "/", params: { serverId } });
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -389,13 +344,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
// Match /agent/[serverId]/[agentId] pattern
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)\/?$/);
|
||||
if (match) {
|
||||
const [, serverId, agentId] = match;
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
return undefined;
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
@@ -478,14 +428,14 @@ export default function RootLayout() {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="agent" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen
|
||||
name="agent/[serverId]/[agentId]"
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agent/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen";
|
||||
|
||||
export default function LegacyAgentRoute() {
|
||||
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||
return <LegacyAgentIdScreen agentId={typeof id === "string" ? id : ""} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
|
||||
export default function AgentReadyRoute() {
|
||||
const { serverId, agentId } = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={typeof serverId === "string" ? serverId : ""}
|
||||
agentId={typeof agentId === "string" ? agentId : ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function AgentDraftRoute() {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
agentId={typeof params.agentId === "string" ? params.agentId : ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function HostDraftAgentRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentsScreen } from "@/screens/agents-screen";
|
||||
|
||||
export default function HostAgentsRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <AgentsScreen serverId={serverId} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
}, [router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default SettingsScreen;
|
||||
@@ -1,5 +1,59 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function Index() {
|
||||
return <DraftAgentScreen />;
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
buildHostSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -138,8 +142,14 @@ export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ source?: string; targetServerId?: string }>();
|
||||
const params = useLocalSearchParams<{
|
||||
source?: string;
|
||||
sourceServerId?: string;
|
||||
targetServerId?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
const sourceServerId =
|
||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
|
||||
|
||||
@@ -160,34 +170,47 @@ export default function PairScanScreen() {
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace({ pathname: "/", params: { serverId } });
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
return;
|
||||
}
|
||||
if (source === "editHost" && targetServerId) {
|
||||
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
|
||||
const settingsServerId = sourceServerId ?? targetServerId;
|
||||
router.replace({
|
||||
pathname: buildHostSettingsRoute(settingsServerId),
|
||||
params: { editHost: targetServerId },
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
// settings (default): return to previous screen
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
router.replace("/settings");
|
||||
const settingsServerId = sourceServerId ?? serverId;
|
||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||
}
|
||||
},
|
||||
[router, source, targetServerId]
|
||||
[router, source, sourceServerId, targetServerId]
|
||||
);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
if (source === "editHost" && targetServerId) {
|
||||
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
|
||||
const settingsServerId = sourceServerId ?? targetServerId;
|
||||
router.replace({
|
||||
pathname: buildHostSettingsRoute(settingsServerId),
|
||||
params: { editHost: targetServerId },
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
router.replace("/settings");
|
||||
if (sourceServerId) {
|
||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}
|
||||
}, [router, source, targetServerId]);
|
||||
}, [router, source, sourceServerId, targetServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
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, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
|
||||
@@ -31,106 +30,69 @@ function toAggregatedAgent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useAllAgentsList(): AggregatedAgentsResult {
|
||||
export function useAllAgentsList(options?: {
|
||||
serverId?: string | null;
|
||||
}): AggregatedAgentsResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
|
||||
const sessionClients = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<
|
||||
string,
|
||||
NonNullable<typeof state.sessions[string]["client"]> | null
|
||||
> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.client ?? null;
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
);
|
||||
const client = session?.client ?? null;
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["allAgents", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionConnections = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.connection.isConnected;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const liveAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const serverEntries = useMemo(
|
||||
() =>
|
||||
Object.keys(sessionClients).map((serverId) => ({
|
||||
serverId,
|
||||
client: sessionClients[serverId] ?? null,
|
||||
isConnected: sessionConnections[serverId] ?? false,
|
||||
})),
|
||||
[sessionClients, sessionConnections]
|
||||
);
|
||||
|
||||
const queries = useQueries({
|
||||
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
|
||||
queryKey: ["allAgents", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents();
|
||||
},
|
||||
enabled: Boolean(client) && isConnected,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
refetchOnMount: "always" as const,
|
||||
})),
|
||||
return await client.fetchAgents();
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
refetchOnMount: "always" as const,
|
||||
});
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
for (const { serverId } of serverEntries) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", serverId],
|
||||
});
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [queryClient, serverEntries]);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const all: AggregatedAgent[] = [];
|
||||
if (!serverId) {
|
||||
return [];
|
||||
}
|
||||
const data = agentsQuery.data ?? [];
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (let idx = 0; idx < serverEntries.length; idx++) {
|
||||
const entry = serverEntries[idx];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const data = queries[idx]?.data;
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
const serverLabel =
|
||||
connectionStates.get(entry.serverId)?.daemon.label ?? entry.serverId;
|
||||
const liveById = liveAgents[entry.serverId];
|
||||
|
||||
for (const snapshot of data) {
|
||||
const normalized = normalizeAgentSnapshot(snapshot, entry.serverId);
|
||||
const live = liveById?.get(snapshot.id);
|
||||
all.push(
|
||||
toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId: entry.serverId,
|
||||
serverLabel,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const snapshot of data) {
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
const live = liveAgents?.get(snapshot.id);
|
||||
list.push(
|
||||
toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId,
|
||||
serverLabel,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
all.sort((left, right) => {
|
||||
list.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
@@ -142,10 +104,11 @@ export function useAllAgentsList(): AggregatedAgentsResult {
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
});
|
||||
|
||||
return all;
|
||||
}, [serverEntries, queries, connectionStates, liveAgents]);
|
||||
return list;
|
||||
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
|
||||
|
||||
const isFetching = queries.some((query) => query.isPending || query.isFetching);
|
||||
const isFetching =
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && agents.length === 0;
|
||||
const isRevalidating = isFetching && agents.length > 0;
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
@@ -46,19 +52,17 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
}
|
||||
|
||||
function parseAgentKeyFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
return `${match[1]}:${match[2]}`;
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return `${match.serverId}:${match.agentId}`;
|
||||
}
|
||||
|
||||
function parseAgentRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; agentId: string } | null {
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
const [, serverId, agentId] = match;
|
||||
if (!serverId || !agentId) return null;
|
||||
return { serverId, agentId };
|
||||
return parseHostAgentRouteFromPathname(pathname);
|
||||
}
|
||||
|
||||
type CommandCenterActionDefinition = {
|
||||
@@ -67,7 +71,7 @@ type CommandCenterActionDefinition = {
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
keywords: string[];
|
||||
buildRoute: (params: { newAgentRoute: string }) => string;
|
||||
buildRoute: (params: { newAgentRoute: string; settingsRoute: string }) => string;
|
||||
};
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
@@ -84,7 +88,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
title: "Settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
buildRoute: () => "/settings",
|
||||
buildRoute: ({ settingsRoute }) => settingsRoute,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -137,22 +141,26 @@ export function useCommandCenter() {
|
||||
return filtered;
|
||||
}, [agents, query]);
|
||||
|
||||
const fallbackServerId = agents[0]?.serverId ?? null;
|
||||
|
||||
const agentKeyFromPathname = useMemo(
|
||||
() => parseAgentKeyFromPathname(pathname),
|
||||
[pathname]
|
||||
);
|
||||
|
||||
const newAgentRoute = useMemo(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
const routeAgent = parseAgentRouteFromPathname(pathname);
|
||||
if (!routeAgent) {
|
||||
return "/agent";
|
||||
return serverIdFromPath ? buildNewAgentRoute(serverIdFromPath) : "/";
|
||||
}
|
||||
|
||||
const { serverId, agentId } = routeAgent;
|
||||
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = currentAgent?.cwd?.trim();
|
||||
if (!cwd) {
|
||||
return "/agent";
|
||||
return buildNewAgentRoute(serverId);
|
||||
}
|
||||
|
||||
const checkout =
|
||||
@@ -160,8 +168,14 @@ export function useCommandCenter() {
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
return buildNewAgentRoute(workingDir);
|
||||
}, [pathname]);
|
||||
return buildNewAgentRoute(serverId, workingDir);
|
||||
}, [fallbackServerId, pathname]);
|
||||
|
||||
const settingsRoute = useMemo(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
|
||||
}, [fallbackServerId, pathname]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
||||
@@ -171,10 +185,10 @@ export function useCommandCenter() {
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.buildRoute({ newAgentRoute }),
|
||||
route: action.buildRoute({ newAgentRoute, settingsRoute }),
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
}));
|
||||
}, [newAgentRoute, query]);
|
||||
}, [newAgentRoute, query, settingsRoute]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const next: CommandCenterItem[] = [];
|
||||
@@ -203,14 +217,14 @@ export function useCommandCenter() {
|
||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||
session?.client?.clearAgentAttention(agent.id);
|
||||
|
||||
const shouldReplace = pathname.startsWith("/agent/");
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
requestFocusChatInput(agentKey(agent));
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
|
||||
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||
},
|
||||
[pathname, requestFocusChatInput, setOpen]
|
||||
);
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function useGlobalKeyboardNav({
|
||||
enabled,
|
||||
@@ -86,18 +91,20 @@ export function useGlobalKeyboardNav({
|
||||
}
|
||||
const { serverId, agentId } = parsed;
|
||||
|
||||
const shouldReplace = pathname.startsWith("/agent/");
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(`/agent/${serverId}/${agentId}` as any);
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
};
|
||||
|
||||
const navigateToNewAgent = () => {
|
||||
let target = "/agent";
|
||||
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) {
|
||||
@@ -105,13 +112,21 @@ export function useGlobalKeyboardNav({
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
target = buildNewAgentRoute(workingDir);
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.push(target as any);
|
||||
if (!targetServerId) {
|
||||
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
|
||||
targetServerId = sessionServerIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
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";
|
||||
@@ -15,7 +14,8 @@ const SIDEBAR_GROUPS_STALE_TIME = 15_000;
|
||||
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
|
||||
const MAX_AGENTS_PER_PROJECT = 5;
|
||||
|
||||
type SidebarGroupsPayload = FetchAgentsGroupedByProjectResponseMessage["payload"];
|
||||
type SidebarGroupsPayload =
|
||||
FetchAgentsGroupedByProjectResponseMessage["payload"];
|
||||
export type SidebarCheckoutLite =
|
||||
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
|
||||
type MutableSidebarGroup = {
|
||||
@@ -68,95 +68,63 @@ function toAggregatedAgent(params: {
|
||||
|
||||
export function useSidebarAgentsGrouped(options?: {
|
||||
isOpen?: boolean;
|
||||
serverId?: string | null;
|
||||
}): SidebarAgentsGroupedResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const isOpen = options?.isOpen ?? true;
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
|
||||
const sessionClients = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<
|
||||
string,
|
||||
NonNullable<typeof state.sessions[string]["client"]> | null
|
||||
> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.client ?? null;
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
);
|
||||
const client = session?.client ?? null;
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const groupedQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionConnections = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.connection.isConnected;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const serverEntries = useMemo(
|
||||
() =>
|
||||
Object.keys(sessionClients).map((serverId) => ({
|
||||
serverId,
|
||||
client: sessionClients[serverId] ?? null,
|
||||
isConnected: sessionConnections[serverId] ?? false,
|
||||
})),
|
||||
[sessionClients, sessionConnections]
|
||||
);
|
||||
|
||||
const groupedQueries = useQueries({
|
||||
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgentsGroupedByProject({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: Boolean(client) && isConnected,
|
||||
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
|
||||
refetchIntervalInBackground: isOpen,
|
||||
refetchOnMount: "always" as const,
|
||||
})),
|
||||
return await client.fetchAgentsGroupedByProject({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_GROUPS_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(() => {
|
||||
if (!serverId) {
|
||||
return {
|
||||
sections: [] as SidebarSectionData[],
|
||||
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
|
||||
hasAnyData: false,
|
||||
};
|
||||
}
|
||||
|
||||
const groupsByKey = new Map<string, MutableSidebarGroup>();
|
||||
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
|
||||
const seenAgentKeys = new Set<string>();
|
||||
const groupedFetchReadyByServer = new Map<string, boolean>();
|
||||
|
||||
for (let idx = 0; idx < serverEntries.length; idx++) {
|
||||
const { serverId } = serverEntries[idx] ?? {};
|
||||
if (!serverId) {
|
||||
continue;
|
||||
}
|
||||
groupedFetchReadyByServer.set(serverId, groupedQueries[idx]?.isFetched ?? false);
|
||||
const payload = groupedQueries[idx]?.data as SidebarGroupsPayload | undefined;
|
||||
if (!payload) {
|
||||
continue;
|
||||
}
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const liveAgents = sessionAgents[serverId];
|
||||
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
|
||||
const groupedFetchReady = groupedQuery.isFetched;
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
|
||||
if (payload) {
|
||||
for (const group of payload.groups) {
|
||||
const existing: MutableSidebarGroup =
|
||||
groupsByKey.get(group.projectKey) ??
|
||||
@@ -180,7 +148,10 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
|
||||
const agentKey = `${serverId}:${entry.agent.id}`;
|
||||
seenAgentKeys.add(agentKey);
|
||||
checkoutLookup.set(agentKey, live?.projectPlacement?.checkout ?? entry.checkout);
|
||||
checkoutLookup.set(
|
||||
agentKey,
|
||||
live?.projectPlacement?.checkout ?? entry.checkout
|
||||
);
|
||||
existing.agents.push(nextAgent);
|
||||
}
|
||||
|
||||
@@ -188,16 +159,7 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
for (const { serverId } of serverEntries) {
|
||||
if (!groupedFetchReadyByServer.get(serverId)) {
|
||||
continue;
|
||||
}
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const liveAgents = sessionAgents[serverId];
|
||||
if (!liveAgents) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (groupedFetchReady && liveAgents) {
|
||||
for (const live of liveAgents.values()) {
|
||||
if (live.archivedAt || live.labels.ui !== "true") {
|
||||
continue;
|
||||
@@ -270,7 +232,14 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
checkoutByAgentKey: checkoutLookup,
|
||||
hasAnyData: nextSections.length > 0,
|
||||
};
|
||||
}, [serverEntries, groupedQueries, connectionStates, sessionAgents, projectOrder]);
|
||||
}, [
|
||||
connectionStates,
|
||||
groupedQuery.data,
|
||||
groupedQuery.isFetched,
|
||||
liveAgents,
|
||||
projectOrder,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentKeys = sections.map((section) => section.projectKey);
|
||||
@@ -282,16 +251,16 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
}, [sections, projectOrder, setProjectOrder]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
for (const { serverId } of serverEntries) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId],
|
||||
});
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [queryClient, serverEntries]);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
|
||||
const isFetching = groupedQueries.some(
|
||||
(query) => query.isPending || query.isFetching
|
||||
);
|
||||
const isFetching =
|
||||
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
|
||||
const isInitialLoad = isFetching && !hasAnyData;
|
||||
const isRevalidating = isFetching && hasAnyData;
|
||||
|
||||
@@ -304,3 +273,4 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -124,7 +125,11 @@ export function AgentReadyScreen({
|
||||
targetMs: 300,
|
||||
});
|
||||
}
|
||||
router.replace("/agent" as any);
|
||||
if (targetServerId) {
|
||||
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}, [resolvedAgentId, resolvedServerId, router]);
|
||||
|
||||
const focusServerId = resolvedServerId;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { Folder, GitBranch, Menu, Monitor, PanelLeft } from "lucide-react-native";
|
||||
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -47,6 +46,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
|
||||
const DRAFT_AGENT_ID = "__new_agent__";
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
@@ -112,11 +112,13 @@ type DraftAgentParams = {
|
||||
type DraftAgentScreenProps = {
|
||||
isVisible?: boolean;
|
||||
onCreateFlowActiveChange?: (active: boolean) => void;
|
||||
forcedServerId?: string;
|
||||
};
|
||||
|
||||
export function DraftAgentScreen({
|
||||
isVisible = true,
|
||||
onCreateFlowActiveChange,
|
||||
forcedServerId,
|
||||
}: DraftAgentScreenProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
@@ -144,7 +146,11 @@ export function DraftAgentScreen({
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedServerId = getParamValue(params.serverId);
|
||||
const forcedServerIdParam = forcedServerId?.trim();
|
||||
const resolvedServerId =
|
||||
forcedServerIdParam && forcedServerIdParam.length > 0
|
||||
? forcedServerIdParam
|
||||
: getParamValue(params.serverId);
|
||||
const resolvedProvider = getValidProvider(getParamValue(params.provider));
|
||||
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
|
||||
const resolvedModel = getParamValue(params.model);
|
||||
@@ -215,25 +221,9 @@ export function DraftAgentScreen({
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
const daemonLabelByServerId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const daemon of daemons) {
|
||||
const label = daemon.label?.trim();
|
||||
map.set(daemon.serverId, label && label.length > 0 ? label : daemon.serverId);
|
||||
}
|
||||
return map;
|
||||
}, [daemons]);
|
||||
const hostEntry = selectedServerId
|
||||
? connectionStates.get(selectedServerId)
|
||||
: undefined;
|
||||
const selectedDaemonLabel = selectedServerId
|
||||
? daemonLabelByServerId.get(selectedServerId)
|
||||
: null;
|
||||
const hostLabel =
|
||||
selectedDaemonLabel ??
|
||||
hostEntry?.daemon.label ??
|
||||
selectedServerId ??
|
||||
"Select host";
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
@@ -242,7 +232,6 @@ export function DraftAgentScreen({
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
|
||||
const [isHostOpen, setIsHostOpen] = useState(false);
|
||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [worktreeSlug, setWorktreeSlug] = useState("");
|
||||
@@ -252,7 +241,6 @@ export function DraftAgentScreen({
|
||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
||||
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
||||
const hostAnchorRef = useRef<View>(null);
|
||||
const workingDirAnchorRef = useRef<View>(null);
|
||||
const worktreeAnchorRef = useRef<View>(null);
|
||||
const branchAnchorRef = useRef<View>(null);
|
||||
@@ -670,17 +658,6 @@ export function DraftAgentScreen({
|
||||
worktreeMode === "create"
|
||||
? "Create new worktree"
|
||||
: selectedWorktreeLabel || "Select worktree";
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
daemons.map((daemon) => ({
|
||||
id: daemon.serverId,
|
||||
label: daemonLabelByServerId.get(daemon.serverId) ?? daemon.serverId,
|
||||
description: formatConnectionStatus(
|
||||
connectionStates.get(daemon.serverId)?.status ?? "idle"
|
||||
),
|
||||
})),
|
||||
[connectionStates, daemonLabelByServerId, daemons]
|
||||
);
|
||||
const worktreeComboOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -930,7 +907,9 @@ export function DraftAgentScreen({
|
||||
const agentId = (result as { id?: string })?.id;
|
||||
if (agentId && selectedServerId) {
|
||||
updatePendingAgentId(agentId);
|
||||
router.replace(`/agent/${selectedServerId}/${agentId}` as any);
|
||||
router.replace(
|
||||
buildHostAgentDetailRoute(selectedServerId, agentId) as any
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1031,7 +1010,7 @@ export function DraftAgentScreen({
|
||||
<View style={isMobile ? styles.stackedSelectorGroup : styles.topSelectorRow}>
|
||||
<FormSelectTrigger
|
||||
controlRef={workingDirAnchorRef}
|
||||
containerStyle={isMobile ? styles.fullSelector : styles.topSelectorPrimary}
|
||||
containerStyle={styles.fullSelector}
|
||||
label="Working directory"
|
||||
value={displayWorkingDir}
|
||||
placeholder="/path/to/project"
|
||||
@@ -1040,16 +1019,6 @@ export function DraftAgentScreen({
|
||||
showLabel={false}
|
||||
valueEllipsizeMode="middle"
|
||||
/>
|
||||
<FormSelectTrigger
|
||||
controlRef={hostAnchorRef}
|
||||
containerStyle={isMobile ? styles.fullSelector : styles.topSelectorSecondary}
|
||||
label="Host"
|
||||
value={hostLabel}
|
||||
placeholder="Select host"
|
||||
onPress={() => setIsHostOpen(true)}
|
||||
icon={<Monitor size={16} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
{isDirectoryNotExists && (
|
||||
<View style={styles.warningContainer}>
|
||||
@@ -1117,17 +1086,6 @@ export function DraftAgentScreen({
|
||||
{attachWorktreeError ? <Text style={styles.errorInlineText}>{attachWorktreeError}</Text> : null}
|
||||
{worktreeOptionsError ? <Text style={styles.errorInlineText}>{worktreeOptionsError}</Text> : null}
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={selectedServerId ?? ""}
|
||||
onSelect={(serverId) => setSelectedServerIdFromUser(serverId)}
|
||||
title="Host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostOpen}
|
||||
onOpenChange={setIsHostOpen}
|
||||
anchorRef={hostAnchorRef}
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
options={worktreeComboOptions}
|
||||
value={
|
||||
|
||||
@@ -7,6 +7,10 @@ import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSessionDirectory } from "@/hooks/use-session-directory";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostAgentDraftRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
type AgentMatch = {
|
||||
serverId: string;
|
||||
@@ -50,16 +54,21 @@ export function LegacyAgentIdScreen({ agentId }: { agentId: string }) {
|
||||
return;
|
||||
}
|
||||
const match = matches[0];
|
||||
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
|
||||
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
|
||||
}, [isRedirecting, matches, router]);
|
||||
|
||||
const handleGoDraft = useCallback(() => {
|
||||
router.replace("/agent" as any);
|
||||
}, [router]);
|
||||
const firstMatchServerId = matches[0]?.serverId ?? null;
|
||||
if (firstMatchServerId) {
|
||||
router.replace(buildHostAgentDraftRoute(firstMatchServerId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}, [matches, router]);
|
||||
|
||||
const handleSelectMatch = useCallback(
|
||||
(match: AgentMatch) => {
|
||||
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
|
||||
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
@@ -4,9 +4,13 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export default function AgentsScreen() {
|
||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList();
|
||||
export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
|
||||
serverId,
|
||||
});
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -33,7 +37,10 @@ export default function AgentsScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="All agents" />
|
||||
<BackHeader
|
||||
title="All agents"
|
||||
onBack={() => router.replace(buildHostAgentDraftRoute(serverId) as any)}
|
||||
/>
|
||||
<AgentList
|
||||
agents={sortedAgents}
|
||||
showCheckoutInfo={false}
|
||||
@@ -405,7 +405,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
export default function SettingsScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams<{ editHost?: string }>();
|
||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||
const {
|
||||
daemons,
|
||||
@@ -674,16 +675,19 @@ export default function SettingsScreen() {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsPasteLinkVisible(true);
|
||||
}}
|
||||
onScanQr={() => {
|
||||
const targetServerId = addConnectionTargetServerId;
|
||||
const source = targetServerId ? "editHost" : "settings";
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
pathname: "/pair-scan",
|
||||
params: targetServerId ? { source, targetServerId } : { source },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
onScanQr={() => {
|
||||
const targetServerId = addConnectionTargetServerId;
|
||||
const source = targetServerId ? "editHost" : "settings";
|
||||
const sourceServerId = routeServerId || targetServerId || undefined;
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
pathname: "/pair-scan",
|
||||
params: targetServerId
|
||||
? { source, targetServerId, sourceServerId }
|
||||
: { source, sourceServerId },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectHostVisible}
|
||||
@@ -0,0 +1,118 @@
|
||||
type NullableString = string | null | undefined;
|
||||
|
||||
function trimNonEmpty(value: NullableString): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const raw = match[1];
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return trimNonEmpty(decodeURIComponent(raw));
|
||||
} catch {
|
||||
return trimNonEmpty(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseHostAgentRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; agentId: string } | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, encodedServerId, encodedAgentId] = match;
|
||||
if (!encodedServerId || !encodedAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decode = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const serverId = trimNonEmpty(decode(encodedServerId));
|
||||
const agentId = trimNonEmpty(decode(encodedAgentId));
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function buildHostAgentDraftRoute(serverId: string): string {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
}
|
||||
return `/h/${encodeSegment(normalized)}/agent`;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string
|
||||
): string {
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedServerId || !normalizedAgentId) {
|
||||
return "/";
|
||||
}
|
||||
return `/h/${encodeSegment(normalizedServerId)}/agent/${encodeSegment(
|
||||
normalizedAgentId
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function buildHostAgentsRoute(serverId: string): string {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
}
|
||||
return `/h/${encodeSegment(normalized)}/agents`;
|
||||
}
|
||||
|
||||
export function buildHostSettingsRoute(serverId: string): string {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
}
|
||||
return `/h/${encodeSegment(normalized)}/settings`;
|
||||
}
|
||||
|
||||
export function mapPathnameToServer(
|
||||
pathname: string,
|
||||
nextServerId: string
|
||||
): string {
|
||||
const normalized = trimNonEmpty(nextServerId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
|
||||
const base = `/h/${encodeSegment(normalized)}`;
|
||||
if (suffix.startsWith("settings")) {
|
||||
return `${base}/settings`;
|
||||
}
|
||||
if (suffix.startsWith("agents")) {
|
||||
return `${base}/agents`;
|
||||
}
|
||||
return `${base}/agent`;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
} from "./new-agent-routing";
|
||||
|
||||
describe("buildNewAgentRoute", () => {
|
||||
it("falls back to /agent when no working directory is provided", () => {
|
||||
expect(buildNewAgentRoute(undefined)).toBe("/agent");
|
||||
expect(buildNewAgentRoute(" ")).toBe("/agent");
|
||||
it("falls back to host-scoped draft route when no working directory is provided", () => {
|
||||
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/agent");
|
||||
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/agent");
|
||||
});
|
||||
|
||||
it("encodes the working directory query parameter", () => {
|
||||
expect(buildNewAgentRoute("/Users/me/dev/paseo")).toBe(
|
||||
"/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
|
||||
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
|
||||
"/h/srv-1/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
export function resolveNewAgentWorkingDir(
|
||||
cwd: string,
|
||||
@@ -7,10 +8,17 @@ export function resolveNewAgentWorkingDir(
|
||||
return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd;
|
||||
}
|
||||
|
||||
export function buildNewAgentRoute(workingDir?: string | null): string {
|
||||
export function buildNewAgentRoute(
|
||||
serverId: string,
|
||||
workingDir?: string | null
|
||||
): string {
|
||||
const baseRoute = buildHostAgentDraftRoute(serverId);
|
||||
if (baseRoute === "/") {
|
||||
return baseRoute;
|
||||
}
|
||||
const trimmedWorkingDir = workingDir?.trim();
|
||||
if (!trimmedWorkingDir) {
|
||||
return "/agent";
|
||||
return baseRoute;
|
||||
}
|
||||
return `/agent?workingDir=${encodeURIComponent(trimmedWorkingDir)}`;
|
||||
return `${baseRoute}?workingDir=${encodeURIComponent(trimmedWorkingDir)}`;
|
||||
}
|
||||
|
||||
@@ -33,16 +33,17 @@ describe("resolveNotificationTarget", () => {
|
||||
describe("buildNotificationRoute", () => {
|
||||
it("routes directly to server-scoped agent path when both ids are present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
|
||||
"/agent/srv-1/agent-1"
|
||||
"/h/srv-1/agent/agent-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to legacy agent route when serverId is absent", () => {
|
||||
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy");
|
||||
it("falls back to host-scoped draft route when only serverId is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/agent");
|
||||
});
|
||||
|
||||
it("falls back to agents list when no agent id is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents");
|
||||
it("falls back to root when no server id is present", () => {
|
||||
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/");
|
||||
expect(buildNotificationRoute(undefined)).toBe("/");
|
||||
});
|
||||
|
||||
it("encodes path segments", () => {
|
||||
@@ -51,6 +52,6 @@ describe("buildNotificationRoute", () => {
|
||||
serverId: "srv/with/slash",
|
||||
agentId: "agent with space",
|
||||
})
|
||||
).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space");
|
||||
).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostAgentDraftRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
|
||||
function readNonEmptyString(
|
||||
@@ -25,10 +30,10 @@ export function resolveNotificationTarget(data: NotificationData): {
|
||||
export function buildNotificationRoute(data: NotificationData): string {
|
||||
const { serverId, agentId } = resolveNotificationTarget(data);
|
||||
if (serverId && agentId) {
|
||||
return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`;
|
||||
return buildHostAgentDetailRoute(serverId, agentId);
|
||||
}
|
||||
if (agentId) {
|
||||
return `/agent/${encodeURIComponent(agentId)}`;
|
||||
if (serverId) {
|
||||
return buildHostAgentDraftRoute(serverId);
|
||||
}
|
||||
return "/agents";
|
||||
return "/";
|
||||
}
|
||||
|
||||
@@ -161,6 +161,6 @@ describe("sendOsNotification", () => {
|
||||
expect(clicked.onclick).toBeTypeOf("function");
|
||||
clicked.onclick?.({} as Event);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1");
|
||||
expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user