diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 161fffb9a..d5c84e4eb 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -876,6 +876,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent); const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); + const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); @@ -897,10 +898,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) if (input.target?.kind === "agent") { unpinWorkspaceAgent(persistenceKey, input.target.agentId); + hideWorkspaceAgent(persistenceKey, input.target.agentId); } closeWorkspaceTab(persistenceKey, normalizedTabId); }, - [closeWorkspaceTab, persistenceKey, unpinWorkspaceAgent], + [closeWorkspaceTab, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent], ); const focusedPaneTabState = useMemo( diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index bcf170103..6bb7b8673 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -196,6 +196,7 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; + hiddenAgentIds?: ReadonlySet | null; } export interface WorkspaceTabSnapshot { @@ -1429,6 +1430,7 @@ export function reconcileWorkspaceTabs( findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); + const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); @@ -1438,6 +1440,9 @@ export function reconcileWorkspaceTabs( visibleAgentIds.add(agentId); } } + for (const agentId of hiddenAgentIds) { + visibleAgentIds.delete(agentId); + } const initialTabs = collectAllTabs(nextLayout.root); const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId)); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index c83d317fc..64155aeda 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -224,6 +224,7 @@ describe("workspace-layout-store actions", () => { layoutByWorkspace: {}, splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, }); vi.restoreAllMocks(); }); @@ -837,6 +838,42 @@ describe("workspace-layout-store actions", () => { }); }); + it("keeps hidden agent intents in memory per workspace without persisting them", () => { + const workspaceKey = createWorkspaceKey(); + const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: "/repo/other-worktree", + }); + + expect(otherWorkspaceKey).toBeTruthy(); + + const store = useWorkspaceLayoutStore.getState(); + store.hideAgent(workspaceKey, "agent-1"); + store.hideAgent(workspaceKey, "agent-1"); + store.hideAgent(otherWorkspaceKey as string, "agent-2"); + + let state = useWorkspaceLayoutStore.getState(); + expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); + expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ + "agent-2", + ]); + + store.unhideAgent(workspaceKey, "agent-1"); + + state = useWorkspaceLayoutStore.getState(); + expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); + expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([ + "agent-2", + ]); + + const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize; + expect(partialize).toBeTypeOf("function"); + expect(partialize?.(state)).toEqual({ + layoutByWorkspace: {}, + splitSizesByWorkspace: {}, + }); + }); + it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => { vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( "67676767-6767-6767-6767-676767676767", @@ -934,4 +971,38 @@ describe("workspace-layout-store actions", () => { expect(layout.focusedPaneId).toBe("main"); expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1"); }); + + it("reconcileTabs does not re-add locally hidden agent tabs", () => { + const workspaceKey = createWorkspaceKey(); + + useWorkspaceLayoutStore.setState((state) => ({ + ...state, + hiddenAgentIdsByWorkspace: { + [workspaceKey]: new Set(["agent-1"]), + }, + })); + + useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + knownAgentIds: ["agent-1"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + }); + + it("explicitly opening an agent tab clears hidden intent", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.hideAgent(workspaceKey, "agent-1"); + store.openTab(workspaceKey, { kind: "agent", agentId: "agent-1" }); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); + expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); + }); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 116bef8fb..3f39b174e 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -65,6 +65,7 @@ interface WorkspaceLayoutStore { layoutByWorkspace: Record; splitSizesByWorkspace: Record>; pinnedAgentIdsByWorkspace: Record>; + hiddenAgentIdsByWorkspace: Record>; openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; @@ -94,6 +95,8 @@ interface WorkspaceLayoutStore { reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void; pinAgent: (workspaceKey: string, agentId: string) => void; unpinAgent: (workspaceKey: string, agentId: string) => void; + hideAgent: (workspaceKey: string, agentId: string) => void; + unhideAgent: (workspaceKey: string, agentId: string) => void; } const MAX_TREE_DEPTH = 4; @@ -106,6 +109,48 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function addAgentIdToWorkspaceSet( + state: Record>, + workspaceKey: string, + agentId: string, +): Record> { + const currentAgentIds = state[workspaceKey] ?? null; + if (currentAgentIds?.has(agentId)) { + return state; + } + + const nextAgentIds = new Set(currentAgentIds ?? []); + nextAgentIds.add(agentId); + return { + ...state, + [workspaceKey]: nextAgentIds, + }; +} + +function removeAgentIdFromWorkspaceSet( + state: Record>, + workspaceKey: string, + agentId: string, +): Record> { + const currentAgentIds = state[workspaceKey] ?? null; + if (!currentAgentIds?.has(agentId)) { + return state; + } + + if (currentAgentIds.size === 1) { + const nextState = { ...state }; + delete nextState[workspaceKey]; + return nextState; + } + + const nextAgentIds = new Set(currentAgentIds); + nextAgentIds.delete(agentId); + return { + ...state, + [workspaceKey]: nextAgentIds, + }; +} + function getWorkspaceLayout( state: Record, workspaceKey: string, @@ -119,6 +164,7 @@ export const useWorkspaceLayoutStore = create()( layoutByWorkspace: {}, splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, openTab: (workspaceKey, target) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTarget = normalizeWorkspaceTabTarget(target); @@ -133,6 +179,14 @@ export const useWorkspaceLayoutStore = create()( }); set((state) => ({ + hiddenAgentIdsByWorkspace: + normalizedTarget.kind !== "agent" + ? state.hiddenAgentIdsByWorkspace + : removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: result.layout, @@ -207,6 +261,14 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ + hiddenAgentIdsByWorkspace: + normalizedTarget.kind !== "agent" + ? state.hiddenAgentIdsByWorkspace + : removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: result.layout, @@ -233,6 +295,11 @@ export const useWorkspaceLayoutStore = create()( } set((state) => ({ + hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: result.layout, @@ -253,6 +320,7 @@ export const useWorkspaceLayoutStore = create()( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, + hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, }, snapshot, ); @@ -478,6 +546,11 @@ export const useWorkspaceLayoutStore = create()( nextPinnedAgentIds.add(normalizedAgentId); return { + hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), pinnedAgentIdsByWorkspace: { ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, @@ -520,6 +593,50 @@ export const useWorkspaceLayoutStore = create()( }; }); }, + hideAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const nextHiddenAgentIdsByWorkspace = addAgentIdToWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ); + if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) { + return state; + } + + return { + hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace, + }; + }); + }, + unhideAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const nextHiddenAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + state.hiddenAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ); + if (nextHiddenAgentIdsByWorkspace === state.hiddenAgentIdsByWorkspace) { + return state; + } + + return { + hiddenAgentIdsByWorkspace: nextHiddenAgentIdsByWorkspace, + }; + }); + }, }), { name: "workspace-layout-state", diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 3ce623701..d170554c5 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -35,6 +35,7 @@ describe("prepareWorkspaceTab", () => { layoutByWorkspace: {}, splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, }); });