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;
};