From d1d56c1a083a191475dc5974749728bccae410f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 3 Jul 2026 18:21:00 +0100 Subject: [PATCH] fix(office): clear stale agent state on inactive workspace (#1592) --- .../sections/agents-section.test.tsx | 213 +++++++++++++++++- .../app-sidebar/sections/agents-section.tsx | 113 ++++++++-- apps/web/src/office-routes.tsx | 4 + 3 files changed, 294 insertions(+), 36 deletions(-) diff --git a/apps/web/components/app-sidebar/sections/agents-section.test.tsx b/apps/web/components/app-sidebar/sections/agents-section.test.tsx index 88b79bb77f..ba5134da19 100644 --- a/apps/web/components/app-sidebar/sections/agents-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.test.tsx @@ -1,10 +1,32 @@ +import { act } from "react"; import { cleanup, render, screen, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentProfile } from "@/lib/state/slices/office/types"; +import { agentProfileId, workspaceId } from "@/lib/types/ids"; +import { listAgentProfiles } from "@/lib/api/domains/office-api"; const routerMock = vi.hoisted(() => ({ push: vi.fn(), })); +const defaultWorkspaceId = workspaceId("workspace-1"); +const staleWorkspaceId = workspaceId("old-workspace"); +const defaultAgentId = "claude"; +const defaultAgentDisplayName = "Claude"; +const defaultAgentModel = "claude-sonnet-4-5"; +const timestamp = "2026-01-01T00:00:00Z"; +type Deferred = { + resolve: (value: T) => void; + promise: Promise; +}; +const createDeferred = () => { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve } as Deferred; +}; + const state = { appSidebar: { sectionExpanded: { @@ -12,20 +34,57 @@ const state = { } as Record, }, office: { - agentProfiles: [], - inboxItems: [], + agentProfiles: [] as AgentProfile[], + projects: [] as Array, + inboxItems: [] as Array, + inboxCount: 0, }, workspaces: { - activeId: "workspace-1", + activeId: defaultWorkspaceId as string | null, }, setOfficeAgentProfiles: vi.fn(), + setProjects: vi.fn(), + setInboxItems: vi.fn(), + setInboxCount: vi.fn(), toggleAppSidebarSection: vi.fn(), setAppSidebarCollapsed: vi.fn(), sessions: { byId: {}, }, + taskSessions: { + items: {}, + }, }; +const noAgentsText = "No agents yet"; +const createAgentProfile = ({ + id, + workspace, + name, +}: { + id: string; + workspace: string; + name: string; +}): AgentProfile => + ({ + id: agentProfileId(id), + workspaceId: workspaceId(workspace), + name, + role: "worker", + status: "idle", + budgetMonthlyCents: 0, + maxConcurrentSessions: 1, + agentId: defaultAgentId, + agentDisplayName: defaultAgentDisplayName, + model: defaultAgentModel, + allowIndexing: false, + autoApprove: false, + cliFlags: [], + cliPassthrough: false, + createdAt: timestamp, + updatedAt: timestamp, + }) as AgentProfile; + vi.mock("@/lib/routing/client-router", () => ({ usePathname: () => "/office", useRouter: () => routerMock, @@ -45,6 +104,13 @@ vi.mock("@/lib/api/domains/office-api", () => ({ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), + useAppStoreApi: () => ({ + getState: () => ({ + workspaces: { + activeId: state.workspaces.activeId, + }, + }), + }), })); vi.mock("@kandev/ui/collapsible", () => ({ @@ -60,12 +126,21 @@ vi.mock("@kandev/ui/tooltip", () => ({ import { AgentsSection } from "./agents-section"; -describe("AgentsSection", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); +const resetOfficeState = () => { + state.office.agentProfiles = []; + state.office.projects = []; + state.office.inboxItems = []; + state.office.inboxCount = 0; + state.workspaces.activeId = defaultWorkspaceId; +}; +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + resetOfficeState(); +}); + +describe("AgentsSection header", () => { it("renders Agent Topology as the header action before Add agent", () => { render(); @@ -75,9 +150,7 @@ describe("AgentsSection", () => { const topology = within(agentsHeader as HTMLElement).getByRole("link", { name: "Agent topology", }); - const addAgent = within(agentsHeader as HTMLElement).getByRole("button", { - name: "Add agent", - }); + const addAgent = within(agentsHeader as HTMLElement).getByRole("button", { name: "Add agent" }); expect(topology.getAttribute("href")).toBe("/office/workspace/org"); expect(topology.compareDocumentPosition(addAgent) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( @@ -85,3 +158,121 @@ describe("AgentsSection", () => { ); }); }); + +describe("AgentsSection stale state cleanup", () => { + it("does not render stale agent links when no office workspace is active", () => { + state.workspaces.activeId = null; + state.office.agentProfiles = [ + createAgentProfile({ + id: "stale-agent", + workspace: staleWorkspaceId, + name: "Stale Agent", + }), + ]; + + render(); + + expect(screen.queryByRole("link", { name: /stale agent/i })).toBeNull(); + expect(screen.getByText(noAgentsText)).toBeTruthy(); + }); + + it("clears all office data when workspace becomes inactive", () => { + state.workspaces.activeId = defaultWorkspaceId; + state.office.agentProfiles = [ + createAgentProfile({ + id: "active-agent", + workspace: defaultWorkspaceId, + name: "Active Agent", + }), + ]; + state.office.projects = [{ id: "project-1" }]; + state.office.inboxItems = [{ id: "item-1" }]; + state.office.inboxCount = 2; + + const { rerender } = render(); + state.workspaces.activeId = null; + rerender(); + + expect(state.setOfficeAgentProfiles).toHaveBeenCalledWith([]); + expect(state.setProjects).toHaveBeenCalledWith([]); + expect(state.setInboxItems).toHaveBeenCalledWith([]); + expect(state.setInboxCount).toHaveBeenCalledWith(0); + }); + + it("does not overwrite stale agent state when workspace changes mid-fetch", async () => { + let resolveAgents: (value: { agents: AgentProfile[] }) => void = () => {}; + const pending = new Promise<{ agents: AgentProfile[] }>((resolve) => { + resolveAgents = resolve; + }); + + vi.mocked(listAgentProfiles).mockReturnValue(pending); + + state.workspaces.activeId = staleWorkspaceId; + render(); + + state.workspaces.activeId = null; + const staleAgents = { + agents: [ + createAgentProfile({ + id: "race-agent", + workspace: staleWorkspaceId, + name: "Race Agent", + }), + ], + }; + + await act(async () => { + resolveAgents(staleAgents); + await pending; + }); + + expect(state.setOfficeAgentProfiles).not.toHaveBeenCalled(); + }); +}); + +describe("AgentsSection request sequencing", () => { + it("applies only the latest response after rapid workspace switches", async () => { + const request1 = createDeferred<{ agents: AgentProfile[] }>(); + const request2 = createDeferred<{ agents: AgentProfile[] }>(); + const request3 = createDeferred<{ agents: AgentProfile[] }>(); + const agentFromFirstRequest = createAgentProfile({ + id: "agent-a", + workspace: defaultWorkspaceId, + name: "First Agent", + }); + const agentFromSecondRequest = createAgentProfile({ + id: "agent-b", + workspace: staleWorkspaceId, + name: "Second Agent", + }); + const agentFromThirdRequest = createAgentProfile({ + id: "agent-c", + workspace: defaultWorkspaceId, + name: "Third Agent", + }); + + vi.mocked(listAgentProfiles) + .mockReturnValueOnce(request1.promise) + .mockReturnValueOnce(request2.promise) + .mockReturnValueOnce(request3.promise); + + state.workspaces.activeId = defaultWorkspaceId; + const { rerender } = render(); + state.workspaces.activeId = staleWorkspaceId; + rerender(); + state.workspaces.activeId = defaultWorkspaceId; + rerender(); + + await act(async () => { + request1.resolve({ agents: [agentFromFirstRequest] }); + request2.resolve({ agents: [agentFromSecondRequest] }); + request3.resolve({ agents: [agentFromThirdRequest] }); + await request1.promise; + await request2.promise; + await request3.promise; + }); + + expect(state.setOfficeAgentProfiles).toHaveBeenCalledTimes(1); + expect(state.setOfficeAgentProfiles).toHaveBeenCalledWith([agentFromThirdRequest]); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/agents-section.tsx b/apps/web/components/app-sidebar/sections/agents-section.tsx index 691f9393cc..292a0d1631 100644 --- a/apps/web/components/app-sidebar/sections/agents-section.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.tsx @@ -1,12 +1,12 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import Link from "@/components/routing/app-link"; import { usePathname, useRouter } from "@/lib/routing/client-router"; import { IconPlus, IconRobot, IconSitemap } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useInOffice } from "@/hooks/use-in-office"; import { useOfficeRefetch } from "@/hooks/use-office-refetch"; import { listAgentProfiles } from "@/lib/api/domains/office-api"; @@ -27,28 +27,27 @@ type AgentsSectionProps = { collapsed: boolean; }; -export function AgentsSection({ collapsed }: AgentsSectionProps) { - const router = useRouter(); - const inOffice = useInOffice(); - const agents = useAppStore((s) => s.office.agentProfiles); - const workspaceId = useAppStore((s) => s.workspaces.activeId); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); +const hasStaleOfficeData = ({ + agentsLength, + inboxCount, + inboxItemsLength, + projectsLength, +}: { + agentsLength: number; + inboxCount: number; + inboxItemsLength: number; + projectsLength: number; +}) => agentsLength > 0 || inboxItemsLength > 0 || projectsLength > 0 || inboxCount > 0; - const refetchAgents = useCallback(async () => { - if (!workspaceId || !inOffice) return; - const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] })); - setOfficeAgentProfiles(res.agents ?? []); - }, [workspaceId, inOffice, setOfficeAgentProfiles]); +function isCurrentWorkspaceResponse( + requestWorkspaceId: string | null, + activeWorkspaceId: string | null, +) { + return activeWorkspaceId !== null && requestWorkspaceId === activeWorkspaceId; +} - useEffect(() => { - refetchAgents(); - }, [refetchAgents]); - - useOfficeRefetch("agents", refetchAgents); - - if (!inOffice) return null; - - const headerAction = ( +function AgentsSectionHeaderAction({ router }: { router: { push: (path: string) => void } }) { + return (
@@ -82,6 +81,70 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
); +} + +export function AgentsSection({ collapsed }: AgentsSectionProps) { + const router = useRouter(); + const inOffice = useInOffice(); + const store = useAppStoreApi(); + const agents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); + const setProjects = useAppStore((s) => s.setProjects); + const setInboxItems = useAppStore((s) => s.setInboxItems); + const setInboxCount = useAppStore((s) => s.setInboxCount); + const projects = useAppStore((s) => s.office.projects); + const inboxItems = useAppStore((s) => s.office.inboxItems); + const inboxCount = useAppStore((s) => s.office.inboxCount); + const visibleAgents = workspaceId ? agents : []; + const fetchSequenceRef = useRef(0); + + const refetchAgents = useCallback(async () => { + if (!workspaceId || !inOffice) return; + const requestId = ++fetchSequenceRef.current; + const requestedWorkspaceId = workspaceId; + const res = await listAgentProfiles(requestedWorkspaceId).catch(() => ({ agents: [] })); + if (!isCurrentWorkspaceResponse(requestedWorkspaceId, store.getState().workspaces.activeId)) + return; + if (requestId !== fetchSequenceRef.current) return; + setOfficeAgentProfiles(res.agents ?? []); + }, [inOffice, setOfficeAgentProfiles, store, workspaceId]); + + useEffect(() => { + refetchAgents(); + }, [refetchAgents]); + + useEffect(() => { + if (!inOffice || workspaceId) return; + if ( + !hasStaleOfficeData({ + agentsLength: agents.length, + inboxCount, + inboxItemsLength: inboxItems.length, + projectsLength: projects.length, + }) + ) + return; + setOfficeAgentProfiles([]); + setProjects([]); + setInboxItems([]); + setInboxCount(0); + }, [ + agents.length, + inboxCount, + inboxItems.length, + inOffice, + projects.length, + setInboxCount, + setInboxItems, + setOfficeAgentProfiles, + setProjects, + workspaceId, + ]); + + useOfficeRefetch("agents", refetchAgents); + + if (!inOffice) return null; return ( } headerActionVisibility="always" defaultExpanded > - {agents.length === 0 ? ( + {visibleAgents.length === 0 ? (

No agents yet

) : ( - agents.map((agent) => ) + visibleAgents.map((agent) => ) )}
); diff --git a/apps/web/src/office-routes.tsx b/apps/web/src/office-routes.tsx index e1f2d76aa8..c8df171e3e 100644 --- a/apps/web/src/office-routes.tsx +++ b/apps/web/src/office-routes.tsx @@ -209,6 +209,10 @@ function useOfficeRouteBootstrap( store.getState().setMeta(metaResponse); if (!activeWorkspaceId) { + store.getState().setOfficeAgentProfiles([]); + store.getState().setProjects([]); + store.getState().setInboxItems([]); + store.getState().setInboxCount(0); setBootstrap({ complete: true, onboardingComplete }); return; }