fix(office): clear stale agent state on inactive workspace (#1592)
This commit is contained in:
@@ -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<T> = {
|
||||
resolve: (value: T) => void;
|
||||
promise: Promise<T>;
|
||||
};
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve } as Deferred<T>;
|
||||
};
|
||||
|
||||
const state = {
|
||||
appSidebar: {
|
||||
sectionExpanded: {
|
||||
@@ -12,20 +34,57 @@ const state = {
|
||||
} as Record<string, boolean>,
|
||||
},
|
||||
office: {
|
||||
agentProfiles: [],
|
||||
inboxItems: [],
|
||||
agentProfiles: [] as AgentProfile[],
|
||||
projects: [] as Array<unknown>,
|
||||
inboxItems: [] as Array<unknown>,
|
||||
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(<AgentsSection collapsed={false} />);
|
||||
|
||||
@@ -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(<AgentsSection collapsed={false} />);
|
||||
|
||||
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(<AgentsSection collapsed={false} />);
|
||||
state.workspaces.activeId = null;
|
||||
rerender(<AgentsSection collapsed={false} />);
|
||||
|
||||
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(<AgentsSection collapsed={false} />);
|
||||
|
||||
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(<AgentsSection collapsed={false} />);
|
||||
state.workspaces.activeId = staleWorkspaceId;
|
||||
rerender(<AgentsSection collapsed={false} />);
|
||||
state.workspaces.activeId = defaultWorkspaceId;
|
||||
rerender(<AgentsSection collapsed={false} />);
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -82,6 +81,70 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<AppSidebarSection
|
||||
@@ -89,14 +152,14 @@ export function AgentsSection({ collapsed }: AgentsSectionProps) {
|
||||
label="Agents"
|
||||
collapsed={collapsed}
|
||||
icon={IconRobot}
|
||||
headerAction={headerAction}
|
||||
headerAction={<AgentsSectionHeaderAction router={router} />}
|
||||
headerActionVisibility="always"
|
||||
defaultExpanded
|
||||
>
|
||||
{agents.length === 0 ? (
|
||||
{visibleAgents.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No agents yet</p>
|
||||
) : (
|
||||
agents.map((agent) => <AgentRow key={agent.id} agent={agent} />)
|
||||
visibleAgents.map((agent) => <AgentRow key={agent.id} agent={agent} />)
|
||||
)}
|
||||
</AppSidebarSection>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user