diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a2c17e684..c1455c5c8 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -802,7 +802,7 @@ function AgentPanelBody({ - {agentId && !isArchivingCurrentAgent ? ( + {agentId && !isArchivingCurrentAgent && !agent?.archivedAt ? ( { - it("keeps archived agents hidden from visible list but present in workspace lookup", () => { + it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { const workspaceId = "/repo/worktree"; const visible = makeAgent({ id: "visible-agent", @@ -82,87 +83,46 @@ describe("workspace agent visibility", () => { workspaceId, }); - expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["visible-agent"]); - expect(result.lookupById.has("visible-agent")).toBe(true); - expect(result.lookupById.has("archived-agent")).toBe(true); - expect(result.lookupById.has("other-workspace-agent")).toBe(false); + expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); + expect(result.knownAgentIds.has("visible-agent")).toBe(true); + expect(result.knownAgentIds.has("archived-agent")).toBe(true); + expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); it("allows explicit route open for archived agent once agents are hydrated", () => { - 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]]); + const knownAgentIds = new Set(["archived-agent"]); expect( canOpenAgentTabFromRoute({ agentId: "archived-agent", agentsHydrated: true, - workspaceAgentLookup: lookup, + knownAgentIds, }) ).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]]); + it("does not prune archived agent tabs when knownAgentIds contains the agent", () => { + const knownAgentIds = new Set(["archived-agent"]); expect( shouldPruneWorkspaceAgentTab({ agentId: "archived-agent", agentsHydrated: true, - workspaceAgentLookup: lookup, + knownAgentIds, }) ).toBe(false); }); - it("prunes agent tabs once agents are hydrated and the agent is missing from the workspace lookup", () => { + it("prunes agent tabs once agents are hydrated and the agent is missing from knownAgentIds", () => { expect( shouldPruneWorkspaceAgentTab({ agentId: "missing-agent", agentsHydrated: true, - workspaceAgentLookup: new Map(), + knownAgentIds: new Set(), }) ).toBe(true); }); - it("sorts same-createdAt agents by lastActivityAt descending", () => { - const workspaceId = "/repo/worktree"; - const createdAt = new Date("2026-03-04T00:00:00.000Z"); - const newerActivity = makeAgent({ - id: "newer-activity", - cwd: workspaceId, - createdAt, - lastActivityAt: new Date("2026-03-04T00:05:00.000Z"), - }); - const olderActivity = makeAgent({ - id: "older-activity", - cwd: workspaceId, - createdAt, - lastActivityAt: new Date("2026-03-04T00:01:00.000Z"), - }); - const sessionAgents = new Map([ - [olderActivity.id, olderActivity], - [newerActivity.id, newerActivity], - ]); - - const result = deriveWorkspaceAgentVisibility({ - sessionAgents, - workspaceId, - }); - - expect(result.visibleAgents.map((agent) => agent.id)).toEqual([ - "newer-activity", - "older-activity", - ]); - }); - it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => { const sessionAgents = new Map([ [ @@ -179,7 +139,33 @@ describe("workspace agent visibility", () => { workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid", }); - expect(result.visibleAgents.map((agent) => agent.id)).toEqual(["slash-agent"]); - expect(result.lookupById.has("slash-agent")).toBe(true); + expect(result.activeAgentIds).toEqual(new Set(["slash-agent"])); + expect(result.knownAgentIds.has("slash-agent")).toBe(true); + }); + + describe("workspaceAgentVisibilityEqual", () => { + it("returns true for identical sets", () => { + const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + const b = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); + }); + + it("returns false when activeAgentIds differ", () => { + const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; + const b = { activeAgentIds: new Set(["b"]), knownAgentIds: new Set(["a"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns false when knownAgentIds differ", () => { + const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; + const b = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a", "b"]) }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns true for empty sets", () => { + const a = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + const b = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); + }); }); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index db79bd9f9..5d90bf7f9 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -1,68 +1,66 @@ import type { Agent } from "@/stores/session-store"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; -function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { - const sorted = [...agents]; - sorted.sort((left, right) => { - const createdAtDelta = right.createdAt.getTime() - left.createdAt.getTime(); - if (createdAtDelta !== 0) { - return createdAtDelta; - } - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); - }); - return sorted; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (!value) { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - function normalizeWorkspaceId(value: string | null | undefined): string { return normalizeWorkspaceIdentity(value) ?? ""; } +export interface WorkspaceAgentVisibility { + activeAgentIds: Set; + knownAgentIds: Set; +} + export function deriveWorkspaceAgentVisibility(input: { sessionAgents: Map | undefined; workspaceId: string; -}): { - visibleAgents: Agent[]; - lookupById: Map; -} { +}): WorkspaceAgentVisibility { const { sessionAgents, workspaceId } = input; const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId); if (!sessionAgents || !workspaceId) { return { - visibleAgents: [], - lookupById: new Map(), + activeAgentIds: new Set(), + knownAgentIds: new Set(), }; } - const lookupById = new Map(); - const visible: Agent[] = []; + const activeAgentIds = new Set(); + const knownAgentIds = new Set(); for (const agent of sessionAgents.values()) { if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) { continue; } - lookupById.set(agent.id, agent); + knownAgentIds.add(agent.id); if (!agent.archivedAt) { - visible.push(agent); + activeAgentIds.add(agent.id); } } - return { - visibleAgents: sortAgentsByCreatedAtDescending(visible), - lookupById, - }; + return { activeAgentIds, knownAgentIds }; +} + +export function workspaceAgentVisibilityEqual( + a: WorkspaceAgentVisibility, + b: WorkspaceAgentVisibility, +): boolean { + return setsEqual(a.activeAgentIds, b.activeAgentIds) && setsEqual(a.knownAgentIds, b.knownAgentIds); +} + +function setsEqual(a: Set, b: Set): boolean { + if (a.size !== b.size) { + return false; + } + for (const item of a) { + if (!b.has(item)) { + return false; + } + } + return true; } export function canOpenAgentTabFromRoute(input: { agentId: string; agentsHydrated: boolean; - workspaceAgentLookup: Map; + knownAgentIds: Set; }): boolean { if (!input.agentId.trim()) { return false; @@ -70,13 +68,13 @@ export function canOpenAgentTabFromRoute(input: { if (!input.agentsHydrated) { return true; } - return input.workspaceAgentLookup.has(input.agentId); + return input.knownAgentIds.has(input.agentId); } export function shouldPruneWorkspaceAgentTab(input: { agentId: string; agentsHydrated: boolean; - workspaceAgentLookup: Map; + knownAgentIds: Set; }): boolean { if (!input.agentId.trim()) { return false; @@ -84,5 +82,5 @@ export function shouldPruneWorkspaceAgentTab(input: { if (!input.agentsHydrated) { return false; } - return !input.workspaceAgentLookup.has(input.agentId); + return !input.knownAgentIds.has(input.agentId); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a61eb9a5c..fbecfc0f7 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, @@ -96,6 +97,7 @@ import { import { deriveWorkspaceAgentVisibility, shouldPruneWorkspaceAgentTab, + workspaceAgentVisibilityEqual, } from "@/screens/workspace/workspace-agent-visibility"; import { deriveWorkspacePaneState, @@ -582,18 +584,15 @@ function WorkspaceScreenContent({ const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const sessionAgents = useSessionStore( - (state) => state.sessions[normalizedServerId]?.agents - ); - const workspaceAgentVisibility = useMemo( - () => + const workspaceAgentVisibility = useStoreWithEqualityFn( + useSessionStore, + (state) => deriveWorkspaceAgentVisibility({ - sessionAgents, + sessionAgents: state.sessions[normalizedServerId]?.agents, workspaceId: normalizedWorkspaceId, }), - [normalizedWorkspaceId, sessionAgents] + workspaceAgentVisibilityEqual, ); - const workspaceAgents = workspaceAgentVisibility.visibleAgents; const terminalsQueryKey = useMemo( () => ["terminals", normalizedServerId, normalizedWorkspaceId] as const, @@ -823,8 +822,6 @@ function WorkspaceScreenContent({ return () => handler.remove(); }, [closeToAgent, isExplorerOpen]); - const agentsById = workspaceAgentVisibility.lookupById; - const persistenceKey = useMemo( () => buildWorkspaceTabPersistenceKey({ @@ -853,7 +850,6 @@ 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( @@ -957,43 +953,6 @@ 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; @@ -1075,12 +1034,12 @@ function WorkspaceScreenContent({ return pending?.serverId === normalizedServerId && pending.lifecycle === "active"; }); - for (const agent of workspaceAgents) { + for (const agentId of workspaceAgentVisibility.activeAgentIds) { const representedByTarget = uiTabs.some( - (tab) => tab.target.kind === "agent" && tab.target.agentId === agent.id + (tab) => tab.target.kind === "agent" && tab.target.agentId === agentId ); const representedByDeterministicTabId = uiTabs.some( - (tab) => tab.tabId === `agent_${agent.id}` + (tab) => tab.tabId === `agent_${agentId}` ); if ( hasActivePendingDraftCreateInWorkspace && @@ -1089,7 +1048,7 @@ function WorkspaceScreenContent({ ) { continue; } - ensureWorkspaceTab({ kind: "agent", agentId: agent.id }); + ensureWorkspaceTab({ kind: "agent", agentId }); } for (const terminal of terminals) { ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id }); @@ -1104,7 +1063,7 @@ function WorkspaceScreenContent({ shouldPruneWorkspaceAgentTab({ agentId: tab.target.agentId, agentsHydrated: hasHydratedAgents, - workspaceAgentLookup: agentsById, + knownAgentIds: workspaceAgentVisibility.knownAgentIds, }) ) { closeWorkspaceTab(persistenceKey, tab.tabId); @@ -1126,7 +1085,7 @@ function WorkspaceScreenContent({ terminals, terminalsQuery.isSuccess, uiTabs, - workspaceAgents, + workspaceAgentVisibility, ]); const activeTabId = resolvedPaneTabState.activeTabId; @@ -1180,7 +1139,7 @@ function WorkspaceScreenContent({ emptyWorkspaceSeedRef.current = null; return; } - if (workspaceAgents.length > 0 || terminals.length > 0) { + if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) { emptyWorkspaceSeedRef.current = null; return; } @@ -1202,7 +1161,7 @@ function WorkspaceScreenContent({ persistenceKey, terminals.length, tabs.length, - workspaceAgents.length, + workspaceAgentVisibility.activeAgentIds.size, ]); const handleOpenFileFromExplorer = useCallback( @@ -1485,7 +1444,7 @@ function WorkspaceScreenContent({ const handleCopyResumeCommand = useCallback( async (agentId: string) => { if (!agentId) return; - const agent = sessionAgents?.get(agentId) ?? null; + const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; const providerSessionId = agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; if (!agent || !providerSessionId) { @@ -1510,7 +1469,7 @@ function WorkspaceScreenContent({ toast.error("Copy failed"); } }, - [sessionAgents, toast] + [normalizedServerId, toast] ); const handleCopyWorkspacePath = useCallback(async () => {