refactor(app): unified navigateToAgent (#841)

* refactor(app): unify agent navigation

* fix(app): resolve archived agent navigation
This commit is contained in:
Mohamed Boudra
2026-05-09 22:42:03 +08:00
committed by GitHub
parent a4bcc7b169
commit 4f16056578
7 changed files with 191 additions and 57 deletions
+3 -23
View File
@@ -59,7 +59,6 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
@@ -72,7 +71,6 @@ import {
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
@@ -85,13 +83,12 @@ import {
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
@@ -118,25 +115,8 @@ function PushNotificationRouter() {
const serverId = target.serverId;
const agentId = target.agentId;
if (serverId && agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId);
const workspaceId =
target.workspaceId ??
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (workspaceId) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
pin: true,
});
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
return;
}
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
+3 -16
View File
@@ -10,7 +10,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router, type Href } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
@@ -19,9 +18,7 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -312,23 +309,13 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href);
return;
}
rememberArchivedAgentDetail(agent);
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
workspaceId,
target: { kind: "agent", agentId },
agentId,
pin: Boolean(agent.archivedAt),
});
},
@@ -17,6 +17,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -190,6 +191,14 @@ export function WorkspaceSetupDialog() {
}
clearWorkspaceSetup();
if (target.kind === "agent") {
navigateToAgent({
serverId: pendingWorkspaceSetup.serverId,
agentId: target.agentId,
});
return;
}
navigateToPreparedWorkspaceTab({
serverId: pendingWorkspaceSetup.serverId,
workspaceId,
+4 -15
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -11,15 +10,14 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
@@ -204,18 +202,9 @@ export function useCommandCenter() {
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href);
return;
}
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId: agent.serverId,
workspaceId,
target: { kind: "agent", agentId: agent.id },
agentId: agent.id,
currentPathname: pathname,
});
},
+4 -3
View File
@@ -58,6 +58,7 @@ import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
interface ChatAgentStateShape {
@@ -1258,14 +1259,14 @@ function ActiveAgentComposer({
const paneContext = usePaneContext();
const { workspaceId } = paneContext;
const subagentRows = useSubagentsForParent({
serverId: paneContext.serverId,
serverId,
parentAgentId: agentId,
});
const handleOpenSubagent = useCallback(
(subagentId: string) => {
paneContext.openTab({ kind: "agent", agentId: subagentId });
navigateToAgent({ serverId, agentId: subagentId });
},
[paneContext],
[serverId],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const agentInputDraft = useAgentInputDraft({
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { routerMock } = vi.hoisted(() => ({
routerMock: {
dismissTo: vi.fn(),
navigate: vi.fn(),
replace: vi.fn(),
},
}));
vi.mock("expo-router", () => ({
router: routerMock,
useLocalSearchParams: () => ({}),
usePathname: () => "/",
}));
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "workspace-1";
const AGENT_ID = "agent-1";
function createWorkspace(): WorkspaceDescriptor {
return {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo/worktree",
projectKind: "git",
workspaceKind: "local_checkout",
name: "worktree",
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
};
}
function createAgent(input: Partial<Agent> = {}): Agent {
return {
serverId: SERVER_ID,
id: AGENT_ID,
provider: "codex",
status: "closed",
createdAt: new Date("2026-05-09T00:00:00.000Z"),
updatedAt: new Date("2026-05-09T00:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-05-09T00:00:00.000Z"),
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Archived agent",
cwd: "/repo/worktree",
model: null,
thinkingOptionId: null,
archivedAt: new Date("2026-05-09T00:00:00.000Z"),
parentAgentId: null,
labels: {},
...input,
};
}
describe("navigateToAgent", () => {
beforeEach(() => {
routerMock.dismissTo.mockReset();
routerMock.navigate.mockReset();
routerMock.replace.mockReset();
useSessionStore.getState().clearSession(SERVER_ID);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, createWorkspace()]]));
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
});
});
it("opens archived agent details through the resolved workspace", () => {
useSessionStore.getState().setAgentDetails(SERVER_ID, new Map([[AGENT_ID, createAgent()]]));
const route = navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true });
expect(route).toBe("/h/server-1/workspace/workspace-1");
expect(routerMock.navigate).not.toHaveBeenCalled();
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-1");
const key = `${SERVER_ID}:${WORKSPACE_ID}`;
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toEqual([
expect.objectContaining({ target: { kind: "agent", agentId: AGENT_ID } }),
]);
expect(useWorkspaceLayoutStore.getState().pinnedAgentIdsByWorkspace[key]).toEqual(
new Set([AGENT_ID]),
);
});
it("falls back to the host agent route when the workspace is unknown", () => {
const route = navigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" });
expect(route).toBe("/h/server-1/agent/missing-agent");
expect(routerMock.navigate).toHaveBeenCalledWith("/h/server-1/agent/missing-agent");
expect(routerMock.dismissTo).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,35 @@
import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
interface NavigateToAgentInput {
serverId: string;
agentId: string;
currentPathname?: string | null;
pin?: boolean;
}
export function navigateToAgent(input: NavigateToAgentInput): string {
const session = useSessionStore.getState().sessions[input.serverId];
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces.values(),
workspaceDirectory: agent?.cwd,
});
if (!workspaceId) {
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
router.navigate(route as Href);
return route;
}
return navigateToPreparedWorkspaceTab({
serverId: input.serverId,
workspaceId,
target: { kind: "agent", agentId: input.agentId },
currentPathname: input.currentPathname,
pin: input.pin,
});
}