diff --git a/docs/qa-evidence/foreign-agent-tabs/after.png b/docs/qa-evidence/foreign-agent-tabs/after.png new file mode 100644 index 0000000000..77f3c92d8b Binary files /dev/null and b/docs/qa-evidence/foreign-agent-tabs/after.png differ diff --git a/docs/qa-evidence/foreign-agent-tabs/before.png b/docs/qa-evidence/foreign-agent-tabs/before.png new file mode 100644 index 0000000000..aeab42403e Binary files /dev/null and b/docs/qa-evidence/foreign-agent-tabs/before.png differ diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index 7d8b484c77..a902fde117 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -11,7 +11,10 @@ import { expectWorkspaceTabVisible, openWorkspaceWithAgents, } from "../support/helpers/archive-tab"; -import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout"; +import { + expectAgentAbsentFromWorkspaceLayout, + seedForeignPinnedAgentTab, +} from "../support/helpers/workspace-layout"; import { clickNewChat } from "../support/helpers/launcher"; import { expectComposerVisible } from "../support/helpers/composer"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise { test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); + test("removes a foreign pinned agent when restoring a saved workspace layout", async ({ + page, + }, testInfo) => { + const serverId = getServerId(); + const firstWorkspace = await seedWorkspace({ + repoPrefix: "workspace-owner-", + title: "Original workspace", + }); + const secondWorkspace = await seedWorkspace({ + repoPrefix: "workspace-restored-", + title: "Restored workspace", + }); + try { + const firstAgent = await createMockIdleAgent(firstWorkspace.client, { + cwd: firstWorkspace.repoPath, + workspaceId: firstWorkspace.workspaceId, + title: "Original workspace agent", + }); + const secondAgent = await createMockIdleAgent(secondWorkspace.client, { + cwd: secondWorkspace.repoPath, + workspaceId: secondWorkspace.workspaceId, + title: "Restored workspace agent", + }); + await gotoAppShell(page); + await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + await seedForeignPinnedAgentTab(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + + await page.reload(); + await waitForSidebarHydration(page); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: secondWorkspace.projectDisplayName, + }); + await testInfo.attach("hydrated-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }), + contentType: "image/png", + }); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); + await testInfo.attach("restored-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }), + contentType: "image/png", + }); + + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: firstWorkspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]); + const agents = await firstWorkspace.client.fetchAgents(); + expect( + agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId, + ).toBe(firstWorkspace.workspaceId); + expect( + agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId, + ).toBe(secondWorkspace.workspaceId); + await testInfo.attach("original-workspace", { + body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }), + contentType: "image/png", + }); + } finally { + await secondWorkspace.cleanup(); + await firstWorkspace.cleanup(); + } + }); + test("keeps an offline notification on the agent route until the host connects", async ({ page, }) => { diff --git a/packages/app/e2e/support/helpers/workspace-layout.ts b/packages/app/e2e/support/helpers/workspace-layout.ts index 80c2536fbf..2888fa232a 100644 --- a/packages/app/e2e/support/helpers/workspace-layout.ts +++ b/packages/app/e2e/support/helpers/workspace-layout.ts @@ -1,12 +1,47 @@ import { expect, type Page } from "@playwright/test"; import { z } from "zod"; import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; +import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab, } from "../../../src/workspace-tabs/model"; import { getServerId } from "./server-id"; +export async function seedForeignPinnedAgentTab( + page: Page, + input: { serverId: string; workspaceId: string; agentId: string }, +): Promise { + const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); + if (raw === null) throw new Error("Workspace layout was not persisted"); + const stored = z + .object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() }) + .parse(JSON.parse(raw)); + const key = buildWorkspaceTabPersistenceKey(input); + const layout = key ? stored.state.layoutByWorkspace[key] : null; + if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`); + const opened = openTabInLayoutFocused({ + layout, + target: { kind: "agent", agentId: input.agentId }, + now: 1, + placement: { mode: "focused" }, + explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null, + }); + if (!opened) throw new Error("Failed to seed foreign agent tab"); + stored.state = WorkspaceLayoutPersistedStateSchema.parse({ + ...stored.state, + layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout }, + pinnedAgentIdsByWorkspace: { + ...stored.state.pinnedAgentIdsByWorkspace, + [key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId], + }, + }); + await page.evaluate( + (value) => localStorage.setItem("workspace-layout-state", value), + JSON.stringify(stored), + ); +} + /** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise { const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 79e0b4fc76..cd727a6915 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; + foreignAgentIds?: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs( const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - // An explicit open owns the tab until the agent joins the active directory. - // From then on it follows the normal server archive lifecycle. Detail/cache - // hydration never decides whether the user's target is allowed to stay open. + const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []); + // Explicit opens survive absent directory entries, but not known foreign ownership. + // Once an agent joins this workspace's active directory, normal archive rules apply. const pinnedAgentIds = new Set( - [...(state.pinnedAgentIds ?? [])].filter( - (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), - ), + [...(state.pinnedAgentIds ?? [])].filter((agentId) => { + if (!snapshot.agentsHydrated) { + return true; + } + return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId); + }), ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 59f96fccbc..cc5b8f6e04 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -1,6 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabSnapshot, createWorkspaceAgentVisibilitySelector, @@ -63,6 +64,118 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { + it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => { + const values = new Map(); + const storage = { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { + values.set(key, value); + }, + removeItem: async (key: string) => { + values.delete(key); + }, + }; + const original = createWorkspaceLayoutStore(undefined, storage); + await original.persist.rehydrate(); + const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" }); + for (const workspaceId of ["ws-a", "ws-b"]) { + original.getState().openTab({ + workspaceKey: `srv:${workspaceId}`, + target: { kind: "agent", agentId: agent.id }, + intent: "reveal", + pin: true, + }); + } + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + const sessionAgents = new Map([[agent.id, agent]]); + const beforeHydration = buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }), + agentsHydrated: false, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }); + restored.getState().reconcileTabs("srv:ws-b", beforeHydration); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id])); + for (const workspaceId of ["ws-a", "ws-b"]) { + restored.getState().reconcileTabs( + `srv:${workspaceId}`, + buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }), + agentsHydrated: true, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }), + ); + } + + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-a") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: agent.id }]); + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-b") + .filter((tab) => tab.target.kind === "agent"), + ).toEqual([]); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined(); + }); + + it("uses directory ownership before historical details when identifying foreign agents", () => { + const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID }); + const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" }); + const unknown = makeAgent({ id: "unknown", cwd: "/repo" }); + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map([ + [own.id, own], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + agentDetails: new Map([ + [own.id, { ...own, workspaceId: "ws-2" }], + [foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }], + [unknown.id, { ...unknown, workspaceId: "ws-2" }], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set([own.id])); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + + it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => { + const archived = makeAgent({ + id: "archived", + cwd: "/repo", + workspaceId: WORKSPACE_ID, + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" }; + const unknown = { ...archived, id: "unknown", workspaceId: undefined }; + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map(), + agentDetails: new Map([ + [archived.id, archived], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", @@ -86,6 +199,7 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("excludes archived subagents from active and auto-open", () => { @@ -295,6 +409,7 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"])); }); it("excludes agents without a workspaceId", () => { @@ -308,12 +423,14 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), + foreignAgentIds: new Set(["foreign-agent"]), }; expect( @@ -331,6 +448,7 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, + foreignAgentIds: agentVisibility.foreignAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -469,6 +587,24 @@ describe("workspace agent visibility", () => { expect(after.activeAgentIds).toEqual(new Set([child.id])); expect(after.autoOpenAgentIds).toEqual(new Set([child.id])); }); + + it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => { + seedSession(new Map([[parent.id, parent]])); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const before = select(useSessionStore.getState()); + useSessionStore + .getState() + .setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]])); + const after = select(useSessionStore.getState()); + + expect(after).not.toBe(before); + expect(after.activeAgentIds).toEqual(before.activeAgentIds); + expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds); + expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id])); + }); }); describe("workspaceAgentVisibilityEqual", () => { @@ -476,10 +612,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -488,10 +626,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -500,10 +640,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -512,10 +654,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 3dd1881643..99e127c872 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; -} - -function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { - return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId; + foreignAgentIds: Set; } export function deriveWorkspaceAgentVisibility(input: { @@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: { }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails } = input; const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if ((!sessionAgents && !agentDetails) || !workspaceId) { + if (!workspaceId) { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); + const foreignAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId !== workspaceId) { + if (ownerId) { + foreignAgentIds.add(agent.id); + } continue; } if (!agent.archivedAt) { @@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - return { activeAgentIds, autoOpenAgentIds }; + for (const agent of agentDetails?.values() ?? []) { + if (sessionAgents?.has(agent.id)) { + continue; + } + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId && ownerId !== workspaceId) { + foreignAgentIds.add(agent.id); + } + } + return { activeAgentIds, autoOpenAgentIds, foreignAgentIds }; } interface AgentMapsSnapshot { @@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, + foreignAgentIds: input.agentVisibility.foreignAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual( return ( a === b || (setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && + setsEqual(a.foreignAgentIds, b.foreignAgentIds)) ); }