diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 1222b707c..01fed3f36 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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)); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index c481efb64..4232a63f1 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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), }); }, diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 9b0872d03..cbd751fde 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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, diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 737d3decb..3b1a0ac9d 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -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, }); }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 42e2e45c3..f1f61fa61 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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({ diff --git a/packages/app/src/utils/navigate-to-agent.test.ts b/packages/app/src/utils/navigate-to-agent.test.ts new file mode 100644 index 000000000..4af3802a7 --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent.test.ts @@ -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(); + 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 { + 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(); + }); +}); diff --git a/packages/app/src/utils/navigate-to-agent.ts b/packages/app/src/utils/navigate-to-agent.ts new file mode 100644 index 000000000..9dc73767a --- /dev/null +++ b/packages/app/src/utils/navigate-to-agent.ts @@ -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, + }); +}