diff --git a/src/renderer/src/screens/Office/Office.statusPolling.test.tsx b/src/renderer/src/screens/Office/Office.statusPolling.test.tsx new file mode 100644 index 000000000..6c617e5ca --- /dev/null +++ b/src/renderer/src/screens/Office/Office.statusPolling.test.tsx @@ -0,0 +1,319 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OfficeAgent } from "./office3d/core/types"; + +vi.mock("../../components/useI18n", () => ({ + useI18n: () => ({ + t: (key: string): string => key, + }), +})); + +vi.mock("./office3d/Office3D", () => ({ + default: ({ agents }: { agents: OfficeAgent[] }) => ( +
+ {agents.map((agent) => `${agent.id}:${agent.status}`).join(",")} +
+ ), +})); + +vi.mock("./OneChatModal", () => ({ + default: () => null, +})); + +vi.mock("./RepInteractionPanel", () => ({ + default: () => null, +})); + +import Office from "./Office"; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const profile = { + id: "agent", + name: "Agent", + path: "/profiles/agent", + isDefault: true, + isActive: true, + model: "test-model", + provider: "test-provider", + hasEnv: true, + hasSoul: true, + skillCount: 0, + gatewayRunning: false, +}; + +describe("Office status polling", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("keeps status requests single-flight and ignores a result from the previous profile", async () => { + const alphaTasks = deferred<{ + success: boolean; + data: Array<{ assignee: string; status: string }>; + }>(); + const betaTasks = deferred<{ + success: boolean; + data: Array<{ assignee: string; status: string }>; + }>(); + const kanbanListTasks = vi + .fn() + .mockImplementationOnce(() => alphaTasks.promise) + .mockImplementationOnce(() => betaTasks.promise) + .mockResolvedValue({ success: true, data: [] }); + + Object.defineProperty(window, "hermesAPI", { + configurable: true, + value: { + listProfiles: vi.fn().mockResolvedValue([profile]), + kanbanListTasks, + getGpuStatus: vi.fn().mockRejectedValue(new Error("not available")), + reenableGpu: vi.fn().mockResolvedValue(false), + }, + }); + + const view = render(); + await act(async () => {}); + expect(kanbanListTasks).toHaveBeenCalledTimes(1); + expect(kanbanListTasks).toHaveBeenLastCalledWith({ + status: "running", + profile: "alpha", + }); + + await act(async () => { + view.rerender(); + await vi.advanceTimersByTimeAsync(12_000); + }); + + // The alpha IPC is still pending, so neither the profile change nor three + // interval ticks may start another Kanban subprocess. + expect(kanbanListTasks).toHaveBeenCalledTimes(1); + + await act(async () => { + alphaTasks.resolve({ + success: true, + data: [{ assignee: "agent", status: "running" }], + }); + await Promise.resolve(); + }); + + // Once alpha settles, the queued immediate load for beta starts, but the + // stale alpha result must never render its Working state. + expect(kanbanListTasks).toHaveBeenCalledTimes(2); + expect(kanbanListTasks).toHaveBeenLastCalledWith({ + status: "running", + profile: "beta", + }); + expect(screen.getByTestId("office-agents").textContent).not.toContain( + "agent:working", + ); + + await act(async () => { + betaTasks.resolve({ success: true, data: [] }); + await Promise.resolve(); + }); + + expect(screen.getByTestId("office-agents").textContent).toContain( + "agent:idle", + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000); + }); + expect(kanbanListTasks).toHaveBeenCalledTimes(3); + }); + + it("keeps a rejected profile request single-flight until pending Kanban IPC settles", async () => { + const pendingTasks = deferred<{ + success: boolean; + data: Array<{ assignee: string; status: string }>; + }>(); + const listProfiles = vi + .fn() + .mockRejectedValueOnce(new Error("profiles unavailable")) + .mockResolvedValue([profile]); + const kanbanListTasks = vi + .fn() + .mockImplementationOnce(() => pendingTasks.promise) + .mockResolvedValue({ success: true, data: [] }); + + Object.defineProperty(window, "hermesAPI", { + configurable: true, + value: { + listProfiles, + kanbanListTasks, + getGpuStatus: vi.fn().mockRejectedValue(new Error("not available")), + reenableGpu: vi.fn().mockResolvedValue(false), + }, + }); + + render(); + await act(async () => {}); + const refresh = screen.getByRole("button", { name: "office.refresh" }); + + fireEvent.click(refresh); + await act(async () => { + await vi.advanceTimersByTimeAsync(12_000); + }); + + // The profile failure must not release the shared request while the Kanban + // subprocess is still pending, despite manual and timer-driven refreshes. + expect(listProfiles).toHaveBeenCalledTimes(1); + expect(kanbanListTasks).toHaveBeenCalledTimes(1); + expect(refresh).toBeDisabled(); + + await act(async () => { + pendingTasks.resolve({ success: true, data: [] }); + await Promise.resolve(); + }); + + // The original listProfiles failure still clears the load normally, and a + // later polling tick can start a fresh pair of status requests. + expect(refresh).not.toBeDisabled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000); + }); + expect(listProfiles).toHaveBeenCalledTimes(2); + expect(kanbanListTasks).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("office-agents").textContent).toContain( + "agent:idle", + ); + }); + + it("queries status immediately when the visible profile changes after a settled load", async () => { + const betaTasks = deferred<{ + success: boolean; + data: Array<{ assignee: string; status: string }>; + }>(); + const kanbanListTasks = vi + .fn() + .mockResolvedValueOnce({ success: true, data: [] }) + .mockImplementationOnce(() => betaTasks.promise); + + Object.defineProperty(window, "hermesAPI", { + configurable: true, + value: { + listProfiles: vi.fn().mockResolvedValue([profile]), + kanbanListTasks, + getGpuStatus: vi.fn().mockRejectedValue(new Error("not available")), + reenableGpu: vi.fn().mockResolvedValue(false), + }, + }); + + const view = render(); + await act(async () => {}); + expect(kanbanListTasks).toHaveBeenCalledTimes(1); + + await act(async () => { + view.rerender(); + await Promise.resolve(); + }); + + expect(kanbanListTasks).toHaveBeenCalledTimes(2); + expect(kanbanListTasks).toHaveBeenLastCalledWith({ + status: "running", + profile: "beta", + }); + + await act(async () => { + betaTasks.resolve({ success: true, data: [] }); + await Promise.resolve(); + }); + + await act(async () => { + view.rerender(); + await Promise.resolve(); + }); + await act(async () => { + view.rerender(); + await Promise.resolve(); + }); + expect(kanbanListTasks).toHaveBeenCalledTimes(2); + }); + + it("finishes a queued profile load after a manual request is superseded", async () => { + const staleAlphaTasks = deferred<{ + success: boolean; + data: Array<{ assignee: string; status: string }>; + }>(); + const betaTasks = deferred<{ + success: boolean; + data: Array<{ assignee: string; status: string }>; + }>(); + const kanbanListTasks = vi + .fn() + .mockResolvedValueOnce({ success: true, data: [] }) + .mockImplementationOnce(() => staleAlphaTasks.promise) + .mockImplementationOnce(() => betaTasks.promise); + + Object.defineProperty(window, "hermesAPI", { + configurable: true, + value: { + listProfiles: vi.fn().mockResolvedValue([profile]), + kanbanListTasks, + getGpuStatus: vi.fn().mockRejectedValue(new Error("not available")), + reenableGpu: vi.fn().mockResolvedValue(false), + }, + }); + + const view = render(); + await act(async () => {}); + const refresh = screen.getByRole("button", { name: "office.refresh" }); + expect(refresh).not.toBeDisabled(); + + fireEvent.click(refresh); + await act(async () => {}); + expect(refresh).toBeDisabled(); + expect(kanbanListTasks).toHaveBeenCalledTimes(2); + + await act(async () => { + view.rerender(); + await Promise.resolve(); + }); + expect(kanbanListTasks).toHaveBeenCalledTimes(2); + + await act(async () => { + staleAlphaTasks.resolve({ + success: true, + data: [{ assignee: "agent", status: "running" }], + }); + await Promise.resolve(); + }); + + expect(kanbanListTasks).toHaveBeenCalledTimes(3); + expect(kanbanListTasks).toHaveBeenLastCalledWith({ + status: "running", + profile: "beta", + }); + expect(refresh).toBeDisabled(); + expect(screen.getByTestId("office-agents").textContent).not.toContain( + "agent:working", + ); + + await act(async () => { + betaTasks.resolve({ success: true, data: [] }); + await Promise.resolve(); + }); + + expect(screen.getByTestId("office-agents").textContent).toContain( + "agent:idle", + ); + expect(refresh).not.toBeDisabled(); + }); +}); diff --git a/src/renderer/src/screens/Office/Office.tsx b/src/renderer/src/screens/Office/Office.tsx index c703119da..124eae741 100644 --- a/src/renderer/src/screens/Office/Office.tsx +++ b/src/renderer/src/screens/Office/Office.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { Crown, DoorOpen, @@ -49,6 +56,11 @@ interface OfficeProps { visible?: boolean; } +interface AgentStatusRequest { + profile: string | undefined; + promise: Promise; +} + // The CEO assignment is desktop-local UI state (one agent at a time), persisted // across reloads like the app's other renderer preferences (theme, locale). const CEO_STORAGE_KEY = "hermes:office:ceo"; @@ -113,27 +125,88 @@ function Office({ visible, profile }: OfficeProps): React.JSX.Element { } }, []); // Avoid refetching every time the tab regains visibility within a session; - // only the first reveal and explicit refreshes hit IPC. + // the first reveal, profile changes, and explicit refreshes hit IPC. const loadedOnce = useRef(false); + const lastVisibleProfileRef = useRef(profile); + + // Profile/Kanban IPC can take longer than the polling interval. Share one + // request across initial/manual loads and quiet polls so ticks never stack + // subprocesses, and track the committed profile so stale results cannot land. + const statusRequestRef = useRef(null); + const activeProfileRef = useRef(profile); + useLayoutEffect(() => { + activeProfileRef.current = profile; + }, [profile]); + + const requestAgentStatuses = useCallback((): AgentStatusRequest => { + const activeRequest = statusRequestRef.current; + if (activeRequest) return activeRequest; + + const requestProfile = profile; + const promise = (async (): Promise => { + const [profilesResult, runningTasksResult] = await Promise.allSettled([ + window.hermesAPI.listProfiles(), + window.hermesAPI.kanbanListTasks({ + status: "running", + profile: requestProfile, + }), + ]); + if (profilesResult.status === "rejected") throw profilesResult.reason; + const runningTasks = + runningTasksResult.status === "fulfilled" + ? runningTasksResult.value + : null; + return profilesToOfficeAgents( + profilesResult.value, + runningTasks?.success ? (runningTasks.data ?? []) : null, + ); + })(); + const request = { profile: requestProfile, promise }; + statusRequestRef.current = request; + const clearRequest = (): void => { + if (statusRequestRef.current === request) statusRequestRef.current = null; + }; + void promise.then(clearRequest, clearRequest); + return request; + }, [profile]); const loadAgents = useCallback(async () => { + const requestProfile = profile; setLoading(true); try { - const profiles = await window.hermesAPI.listProfiles(); - setAgents(profilesToOfficeAgents(profiles)); - } catch { - setAgents([]); + // A profile change during a slow request waits for that request to settle, + // then immediately starts the newest profile rather than overlapping it. + while (activeProfileRef.current === requestProfile) { + const request = requestAgentStatuses(); + try { + const next = await request.promise; + if (activeProfileRef.current !== requestProfile) return; + if (request.profile !== requestProfile) continue; + setAgents(next); + break; + } catch { + if (activeProfileRef.current !== requestProfile) return; + if (request.profile !== requestProfile) continue; + setAgents([]); + break; + } + } } finally { - setLoading(false); - loadedOnce.current = true; + if (activeProfileRef.current === requestProfile) { + setLoading(false); + loadedOnce.current = true; + } } - }, []); + }, [profile, requestAgentStatuses]); useEffect(() => { - if (visible && !loadedOnce.current) { + if (!visible) return; + const profileChanged = lastVisibleProfileRef.current !== profile; + lastVisibleProfileRef.current = profile; + if (!loadedOnce.current || profileChanged) { void loadAgents(); } - }, [visible, loadAgents]); + }, [visible, profile, loadAgents]); // GPU state is fixed for the lifetime of the process (changing it requires a // relaunch), so one fetch on first reveal is enough. @@ -158,22 +231,32 @@ function Office({ visible, profile }: OfficeProps): React.JSX.Element { } }, []); - // Background poll: re-read profiles while the tab is visible so a gateway - // starting/stopping flips an agent's status (idle <-> working). The 3D - // controller reacts to that change by walking the agent to its desk or to - // the rest room. We update state only when something actually changed and - // never toggle `loading`, so this stays flicker-free. + // Background poll: re-read profiles and running Kanban cards while the tab is + // visible. A profile walks to its desk while it owns a running card and goes + // idle after that card leaves `running`. Gateway liveness remains separate + // metadata for chat availability and is the fallback when Kanban is + // unavailable. State updates only on a real change, so this stays flicker-free. const refreshAgentStatuses = useCallback(async () => { + // Initial/manual loads and earlier ticks own the single request slot. Skip + // this tick; the interval keeps its existing four-second cadence. + if (statusRequestRef.current) return; + const requestProfile = profile; + const request = requestAgentStatuses(); try { - const profiles = await window.hermesAPI.listProfiles(); - const next = profilesToOfficeAgents(profiles); + const next = await request.promise; + if ( + activeProfileRef.current !== requestProfile || + request.profile !== requestProfile + ) { + return; + } setAgents((prev) => { return officeAgentsChanged(prev, next) ? next : prev; }); } catch { // Transient IPC failures are ignored; the next tick retries. } - }, []); + }, [profile, requestAgentStatuses]); useEffect(() => { if (!visible) return; @@ -183,10 +266,10 @@ function Office({ visible, profile }: OfficeProps): React.JSX.Element { return () => window.clearInterval(interval); }, [visible, refreshAgentStatuses]); - // The initial fetch is driven solely by the visible-guard effect above - // (gated on `!loadedOnce.current`). A second unconditional mount effect used - // to live here too, but when the tab was visible on first render both fired - // in the same commit and raced two concurrent `listProfiles` calls. + // Initial and profile-change fetches are driven solely by the visible-guard + // effect above. A second unconditional mount effect used to live here too, + // but when the tab was visible on first render both fired in the same commit + // and raced two concurrent `listProfiles` calls. // Reset selection / CEO if the underlying profile disappears on refresh. useEffect(() => { diff --git a/src/renderer/src/screens/Office/office3d/agents.test.ts b/src/renderer/src/screens/Office/office3d/agents.test.ts new file mode 100644 index 000000000..94a56a309 --- /dev/null +++ b/src/renderer/src/screens/Office/office3d/agents.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + countRunningTasksByAssignee, + officeAgentsChanged, + profilesToOfficeAgents, + type OfficeProfileInput, + type OfficeTaskInput, +} from "./agents"; + +const profiles: OfficeProfileInput[] = [ + { id: "default", name: "Primary Agent", gatewayRunning: true }, + { + id: "build-agent", + name: "Build Agent", + gatewayRunning: false, + }, + { id: "research-agent", name: "Research Agent", gatewayRunning: true }, +]; + +describe("Office Kanban activity", () => { + it("matches running-card assignees to stable profile IDs", () => { + const tasks: OfficeTaskInput[] = [ + { assignee: "build-agent", status: "running" }, + { assignee: " @BUILD-AGENT ", status: "running" }, + { assignee: "research-agent", status: "done" }, + { assignee: null, status: "running" }, + ]; + + const agents = profilesToOfficeAgents(profiles, tasks); + + expect( + agents.map(({ id, status, activeTaskCount }) => ({ + id, + status, + activeTaskCount, + })), + ).toEqual([ + { id: "default", status: "idle", activeTaskCount: 0 }, + { + id: "build-agent", + status: "working", + activeTaskCount: 2, + }, + { id: "research-agent", status: "idle", activeTaskCount: 0 }, + ]); + }); + + it("falls back to gateway liveness when Kanban is unavailable", () => { + const agents = profilesToOfficeAgents(profiles, null); + + expect( + agents.map(({ id, status, activeTaskCount }) => ({ + id, + status, + activeTaskCount, + })), + ).toEqual([ + { id: "default", status: "working", activeTaskCount: undefined }, + { + id: "build-agent", + status: "idle", + activeTaskCount: undefined, + }, + { + id: "research-agent", + status: "working", + activeTaskCount: undefined, + }, + ]); + }); + + it("counts only running cards with non-empty assignees", () => { + const counts = countRunningTasksByAssignee([ + { assignee: "default", status: "running" }, + { assignee: "DEFAULT", status: "running" }, + { assignee: "default", status: "blocked" }, + { assignee: " ", status: "running" }, + ]); + + expect(Object.fromEntries(counts)).toEqual({ default: 2 }); + }); + + it("detects activity-count changes even when status stays working", () => { + const before = profilesToOfficeAgents(profiles, [ + { assignee: "default", status: "running" }, + ]); + const after = profilesToOfficeAgents(profiles, [ + { assignee: "default", status: "running" }, + { assignee: "default", status: "running" }, + ]); + + expect(officeAgentsChanged(before, after)).toBe(true); + expect(officeAgentsChanged(after, after)).toBe(false); + }); +}); diff --git a/src/renderer/src/screens/Office/office3d/agents.ts b/src/renderer/src/screens/Office/office3d/agents.ts index 96849188a..ce3ddb6a9 100644 --- a/src/renderer/src/screens/Office/office3d/agents.ts +++ b/src/renderer/src/screens/Office/office3d/agents.ts @@ -20,6 +20,12 @@ export interface OfficeProfileInput { gatewayRunning?: boolean; } +/** Minimal Kanban task shape needed to derive live Office activity. */ +export interface OfficeTaskInput { + assignee?: string | null; + status?: string | null; +} + // Stable, pleasant accent colors keyed off the profile name so each agent keeps // the same color between renders. const AGENT_COLORS = [ @@ -43,10 +49,15 @@ function hashName(name: string): number { } /** - * Map a desktop profile to an office agent. Each profile becomes one 3D agent; - * a running gateway reads as "working" (green), otherwise "idle" (amber). + * Map a desktop profile to an office agent. When Kanban activity is available, + * a running assignment reads as "working" (green), otherwise "idle" (amber). + * Gateway liveness is retained as separate metadata and as a compatibility + * fallback for connection modes that cannot query Kanban. */ -export function profileToOfficeAgent(profile: OfficeProfileInput): OfficeAgent { +export function profileToOfficeAgent( + profile: OfficeProfileInput, + activeTaskCount?: number, +): OfficeAgent { const id = profile.id || profile.name; const seed = id || "agent"; const agentName = profile.name; @@ -56,21 +67,54 @@ export function profileToOfficeAgent(profile: OfficeProfileInput): OfficeAgent { id, name: agentName, subtitle: profile.model || profile.provider || null, - status: profile.gatewayRunning ? "working" : "idle", + status: + activeTaskCount === undefined + ? profile.gatewayRunning + ? "working" + : "idle" + : activeTaskCount > 0 + ? "working" + : "idle", color, item: "desk", avatarProfile: createAgentAvatarProfileFromSeed(seed), model: profile.model, provider: profile.provider, gatewayRunning: profile.gatewayRunning, + activeTaskCount, position: "employee", }; } +function normalizeProfileId(value: string): string { + return value.trim().replace(/^@/, "").toLowerCase(); +} + +export function countRunningTasksByAssignee( + tasks: OfficeTaskInput[], +): Map { + const counts = new Map(); + for (const task of tasks) { + if (task.status !== "running" || !task.assignee?.trim()) continue; + const assignee = normalizeProfileId(task.assignee); + counts.set(assignee, (counts.get(assignee) ?? 0) + 1); + } + return counts; +} + export function profilesToOfficeAgents( profiles: OfficeProfileInput[], + tasks?: OfficeTaskInput[] | null, ): OfficeAgent[] { - return profiles.map(profileToOfficeAgent); + if (tasks == null) { + return profiles.map((profile) => profileToOfficeAgent(profile)); + } + + const activeTasks = countRunningTasksByAssignee(tasks); + return profiles.map((profile) => { + const id = normalizeProfileId(profile.id || profile.name); + return profileToOfficeAgent(profile, activeTasks.get(id) ?? 0); + }); } export function officeAgentsChanged( @@ -88,7 +132,8 @@ export function officeAgentsChanged( before.status !== agent.status || before.model !== agent.model || before.provider !== agent.provider || - before.gatewayRunning !== agent.gatewayRunning + before.gatewayRunning !== agent.gatewayRunning || + before.activeTaskCount !== agent.activeTaskCount ); }); } diff --git a/src/renderer/src/screens/Office/office3d/core/types.ts b/src/renderer/src/screens/Office/office3d/core/types.ts index e4b348b8f..a5855dbe1 100644 --- a/src/renderer/src/screens/Office/office3d/core/types.ts +++ b/src/renderer/src/screens/Office/office3d/core/types.ts @@ -19,6 +19,8 @@ export type OfficeAgent = { model?: string; provider?: string; gatewayRunning?: boolean; + /** Number of running Kanban cards currently assigned to this profile. */ + activeTaskCount?: number; /** Org position; defaults to "employee" when unset. The CEO gets a desk. */ position?: AgentPosition; };