From 26c07b671fb3ca464dacbab435e7f0681f4516e1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 12:17:50 +0700 Subject: [PATCH] Fix archived workspace session routing\n\nCloses #128 --- packages/app/src/app/_layout.tsx | 7 +- .../workspace/[workspaceId]/_layout.tsx | 6 +- packages/app/src/components/agent-list.tsx | 9 +- packages/app/src/hooks/use-command-center.ts | 7 +- .../workspace-agent-visibility.test.ts | 28 +++++ .../workspace/workspace-agent-visibility.ts | 14 +++ .../screens/workspace/workspace-screen.tsx | 110 ++++++++++++++++-- 7 files changed, 152 insertions(+), 29 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c785066f0..34cde2bd7 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -608,12 +608,7 @@ export default function RootLayout() { > - - `${params?.serverId}:${params?.workspaceId}` - } - /> + () + const globalParams = useGlobalSearchParams<{ open?: string | string[] }>() const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId @@ -17,7 +19,7 @@ export default function HostWorkspaceLayout() { : params.workspaceId const serverId = serverValue?.trim() ?? '' const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : '' - const openValue = Array.isArray(params.open) ? params.open[0] : params.open + const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open const openIntent = parseWorkspaceOpenIntent(openValue) return ( diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 1f86f3dab..bcce7fdef 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -9,7 +9,7 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useCallback, useMemo, useState, type ReactElement } from 'react' -import { router, usePathname, type Href } from 'expo-router' +import { router, type Href } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { formatTimeAgo } from '@/utils/time' import { shortenPath } from '@/utils/shorten-path' @@ -250,7 +250,6 @@ export function AgentList({ showAttentionIndicator = true, }: AgentListProps) { const { theme } = useUnistyles() - const pathname = usePathname() const insets = useSafeAreaInsets() const [actionAgent, setActionAgent] = useState(null) const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' @@ -270,15 +269,13 @@ export function AgentList({ const serverId = agent.serverId const agentId = agent.id - const shouldReplace = pathname.startsWith('/h/') - const navigate = shouldReplace ? router.replace : router.push onAgentSelect?.() const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href - navigate(route) + router.navigate(route) }, - [isActionSheetVisible, pathname, onAgentSelect] + [isActionSheetVisible, onAgentSelect] ) const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 921b192ea..7e140dfc9 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -14,7 +14,6 @@ import { import { buildHostWorkspaceAgentRoute, buildHostSettingsRoute, - parseHostAgentRouteFromPathname, parseServerIdFromPathname, } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -196,8 +195,6 @@ export function useCommandCenter() { const handleSelectAgent = useCallback( (agent: AggregatedAgent) => { didNavigateRef.current = true; - const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); - const navigate = shouldReplace ? router.replace : router.push; // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); @@ -207,9 +204,9 @@ export function useCommandCenter() { agent.cwd, agent.id ) as Href; - navigate(route); + router.navigate(route); }, - [pathname, setOpen] + [setOpen] ); const openProjectPicker = useOpenProjectPicker(activeServerId); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index a13b6d0b4..80b61dfa8 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -3,6 +3,7 @@ import type { Agent } from "@/stores/session-store"; import { canOpenAgentTabFromRoute, deriveWorkspaceAgentVisibility, + shouldPruneWorkspaceAgentTab, } from "@/screens/workspace/workspace-agent-visibility"; function makeAgent(input: { @@ -104,6 +105,33 @@ describe("workspace agent visibility", () => { ).toBe(true); }); + it("does not prune archived agent tabs when the workspace lookup still contains the agent", () => { + const archivedAgent = makeAgent({ + id: "archived-agent", + cwd: "/repo/worktree", + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const lookup = new Map([[archivedAgent.id, archivedAgent]]); + + expect( + shouldPruneWorkspaceAgentTab({ + agentId: "archived-agent", + agentsHydrated: true, + workspaceAgentLookup: lookup, + }) + ).toBe(false); + }); + + it("prunes agent tabs once agents are hydrated and the agent is missing from the workspace lookup", () => { + expect( + shouldPruneWorkspaceAgentTab({ + agentId: "missing-agent", + agentsHydrated: true, + workspaceAgentLookup: new Map(), + }) + ).toBe(true); + }); + it("sorts same-createdAt agents by lastActivityAt descending", () => { const workspaceId = "/repo/worktree"; const createdAt = new Date("2026-03-04T00:00:00.000Z"); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index 65ff212f2..db79bd9f9 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -72,3 +72,17 @@ export function canOpenAgentTabFromRoute(input: { } return input.workspaceAgentLookup.has(input.agentId); } + +export function shouldPruneWorkspaceAgentTab(input: { + agentId: string; + agentsHydrated: boolean; + workspaceAgentLookup: Map; +}): boolean { + if (!input.agentId.trim()) { + return false; + } + if (!input.agentsHydrated) { + return false; + } + return !input.workspaceAgentLookup.has(input.agentId); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5254a2d6c..274536eec 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -95,8 +95,12 @@ import { } from "@/screens/workspace/workspace-header-source"; import { deriveWorkspaceAgentVisibility, + shouldPruneWorkspaceAgentTab, } from "@/screens/workspace/workspace-agent-visibility"; -import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; +import { + deriveWorkspacePaneState, + type WorkspaceDerivedTab, +} from "@/screens/workspace/workspace-pane-state"; import { buildWorkspacePaneContentModel, WorkspacePaneContent, @@ -148,6 +152,34 @@ function buildOpenIntentKey(input: { return `${input.serverId}:${input.workspaceId}:${openParam}`; } +function openIntentMatchesActiveTab(input: { + openIntent?: WorkspaceOpenIntent | null; + activeTab: WorkspaceDerivedTab | null; +}): boolean { + const { openIntent, activeTab } = input; + if (!openIntent || !activeTab) { + return false; + } + + const target = activeTab.descriptor.target; + if (openIntent.kind !== target.kind) { + return false; + } + if (openIntent.kind === "agent" && target.kind === "agent") { + return target.agentId === openIntent.agentId; + } + if (openIntent.kind === "terminal" && target.kind === "terminal") { + return target.terminalId === openIntent.terminalId; + } + if (openIntent.kind === "file" && target.kind === "file") { + return target.path === openIntent.path; + } + if (openIntent.kind === "draft" && target.kind === "draft") { + return openIntent.draftId === "new" || target.draftId === openIntent.draftId; + } + return false; +} + function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; @@ -796,6 +828,7 @@ function WorkspaceScreenContent({ const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const consumedOpenIntentsRef = useRef(new Set()); + const reopenedArchivedOpenIntentsRef = useRef(new Set()); const pendingCloseTabIdsRef = useRef(new Set()); const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState(null); const currentOpenIntentKey = useMemo( @@ -881,6 +914,43 @@ function WorkspaceScreenContent({ } }, [currentOpenIntentKey, resolvedOpenIntentKey]); + useEffect(() => { + if (!client || !isConnected || openIntent?.kind !== "agent" || !currentOpenIntentKey) { + return; + } + + const agentId = openIntent.agentId.trim(); + if (!agentId) { + return; + } + if (reopenedArchivedOpenIntentsRef.current.has(currentOpenIntentKey)) { + return; + } + + const agent = agentsById.get(agentId) ?? null; + if (!agent?.archivedAt) { + return; + } + + reopenedArchivedOpenIntentsRef.current.add(currentOpenIntentKey); + void client.refreshAgent(agentId).catch((error) => { + console.warn("[WorkspaceScreen] Failed to reopen archived agent", { + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + agentId, + error, + }); + }); + }, [ + agentsById, + client, + currentOpenIntentKey, + isConnected, + normalizedServerId, + normalizedWorkspaceId, + openIntent, + ]); + useEffect(() => { if (!openIntent || !persistenceKey) { return; @@ -904,9 +974,6 @@ function WorkspaceScreenContent({ ...(draftId === "new" ? {} : { draftId }), focus: true, }); - if (tabId) { - setResolvedOpenIntentKey(intentKey); - } return; } @@ -918,15 +985,15 @@ function WorkspaceScreenContent({ : { kind: "file", path: openIntent.path }; const tabId = openWorkspaceTab(persistenceKey, target); if (tabId) { - setResolvedOpenIntentKey(intentKey); + focusWorkspaceTab(persistenceKey, tabId); } }, [ currentOpenIntentKey, + focusWorkspaceTab, openIntent, openWorkspaceDraftTab, openWorkspaceTab, persistenceKey, - resolvedOpenIntentKey, ]); const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey @@ -956,7 +1023,6 @@ function WorkspaceScreenContent({ return; } - const agentIds = new Set(workspaceAgents.map((agent) => agent.id)); const terminalIds = new Set(terminals.map((terminal) => terminal.id)); const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => { if (tab.target.kind !== "draft") { @@ -989,7 +1055,15 @@ function WorkspaceScreenContent({ const canPruneAgentTabs = hasHydratedAgents; const canPruneTerminalTabs = terminalsQuery.isSuccess; for (const tab of uiTabs) { - if (canPruneAgentTabs && tab.target.kind === "agent" && !agentIds.has(tab.target.agentId)) { + if ( + canPruneAgentTabs && + tab.target.kind === "agent" && + shouldPruneWorkspaceAgentTab({ + agentId: tab.target.agentId, + agentsHydrated: hasHydratedAgents, + workspaceAgentLookup: agentsById, + }) + ) { closeWorkspaceTab(persistenceKey, tab.tabId); } if ( @@ -1013,6 +1087,24 @@ function WorkspaceScreenContent({ ]); const activeTabId = resolvedPaneTabState.activeTabId; + const activeTab = resolvedPaneTabState.activeTab; + + useEffect(() => { + if (!openIntent || !currentOpenIntentKey) { + return; + } + if (resolvedOpenIntentKey === currentOpenIntentKey) { + return; + } + if ( + openIntentMatchesActiveTab({ + openIntent, + activeTab, + }) + ) { + setResolvedOpenIntentKey(currentOpenIntentKey); + } + }, [activeTab, currentOpenIntentKey, openIntent, resolvedOpenIntentKey]); useEffect(() => { if (!activeTabId || !persistenceKey) { @@ -1021,8 +1113,6 @@ function WorkspaceScreenContent({ focusWorkspaceTab(persistenceKey, activeTabId); }, [activeTabId, focusWorkspaceTab, persistenceKey]); - const activeTab = resolvedPaneTabState.activeTab; - const tabs = useMemo( () => resolvedPaneTabState.tabs.map((tab) => tab.descriptor), [resolvedPaneTabState.tabs]