fix(app): subagents track display + archive flicker

- Bucket finished subagents as `done` instead of `attention`; drop
  "needs attention" segment from the section header — subagents are
  transient and don't carry an unread state worth surfacing.
- Gate the subagents selector on the existing pending-archive query so
  optimistic archives survive authoritative agent snapshots that wipe
  the local `archivedAt`. Last subagent archived now cleanly hides the
  whole track via the existing empty-rows guard.
This commit is contained in:
Mohamed Boudra
2026-05-09 16:28:09 +07:00
parent 34df55a10a
commit 3eda7dd15b
7 changed files with 253 additions and 80 deletions
@@ -1,10 +1,19 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import type { DaemonClient } from "@server/client/daemon-client";
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
import {
__private__,
applyArchivedAgentCloseResults,
usePendingArchiveAgentIds,
} from "./use-archive-agent";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
@@ -92,6 +101,48 @@ describe("useArchiveAgent", () => {
).toBe(false);
});
it("selects pending archive ids for a single server", () => {
const pendingIds = __private__.selectPendingArchiveAgentIds(
{
"server-a:agent-1": true,
"server-a:agent-2": true,
"server-b:agent-3": true,
},
"server-a",
);
expect(Array.from(pendingIds)).toEqual(["agent-1", "agent-2"]);
});
it("subscribes to pending archive ids through react-query", async () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => usePendingArchiveAgentIds("server-a"), { wrapper });
expect(Array.from(result.current)).toEqual([]);
act(() => {
__private__.setAgentArchiving({
queryClient,
serverId: "server-a",
agentId: "agent-1",
isArchiving: true,
});
__private__.setAgentArchiving({
queryClient,
serverId: "server-b",
agentId: "agent-2",
isArchiving: true,
});
});
await waitFor(() => {
expect(Array.from(result.current)).toEqual(["agent-1"]);
});
});
it("removes an archived agent from cached list payloads", () => {
const payload = {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
+48 -5
View File
@@ -1,9 +1,10 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { agentHistoryQueryKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
const EMPTY_PENDING_ARCHIVE_AGENT_IDS = new Set<string>();
export interface ArchiveAgentInput {
serverId: string;
@@ -67,6 +68,35 @@ function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState {
return queryClient.getQueryData<ArchiveAgentPendingState>(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {};
}
function selectPendingArchiveAgentIds(
pendingState: ArchiveAgentPendingState,
serverId: string,
): ReadonlySet<string> {
const normalizedServerId = serverId.trim();
if (!normalizedServerId) {
return EMPTY_PENDING_ARCHIVE_AGENT_IDS;
}
const prefix = `${normalizedServerId}:`;
let agentIds: string[] | null = null;
for (const key of Object.keys(pendingState)) {
if (!key.startsWith(prefix)) {
continue;
}
const agentId = key.slice(prefix.length);
if (!agentId) {
continue;
}
agentIds ??= [];
agentIds.push(agentId);
}
if (!agentIds || agentIds.length === 0) {
return EMPTY_PENDING_ARCHIVE_AGENT_IDS;
}
return new Set(agentIds);
}
function setAgentArchiving(input: SetAgentArchivingInput): void {
const key = toArchiveKey(input);
if (!key) {
@@ -329,16 +359,28 @@ export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
});
}
export function useArchiveAgent() {
const queryClient = useQueryClient();
const pendingQuery = useQuery({
function useArchiveAgentPendingQuery() {
return useQuery({
queryKey: ARCHIVE_AGENT_PENDING_QUERY_KEY,
queryFn: async (): Promise<ArchiveAgentPendingState> => ({}),
initialData: {} as ArchiveAgentPendingState,
staleTime: Infinity,
gcTime: Infinity,
});
}
export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string> {
const pendingQuery = useArchiveAgentPendingQuery();
return useMemo(
() => selectPendingArchiveAgentIds(pendingQuery.data ?? {}, serverId),
[pendingQuery.data, serverId],
);
}
export function useArchiveAgent() {
const queryClient = useQueryClient();
const pendingQuery = useArchiveAgentPendingQuery();
const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
@@ -434,6 +476,7 @@ export function useArchiveAgent() {
export const __private__ = {
toArchiveKey,
readPendingState,
selectPendingArchiveAgentIds,
setAgentArchiving,
isAgentArchiving,
removeAgentFromListPayload,
@@ -209,10 +209,14 @@ describe("workspace subagents integration", () => {
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(
selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-agent",
}).map((row) => row.id),
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-agent",
},
new Set(),
).map((row) => row.id),
).toEqual(["child-agent"]);
});
});
+9 -15
View File
@@ -212,7 +212,7 @@ describe("SubagentsSection", () => {
});
describe("header copy", () => {
it("renders '2 subagents' when two rows have no running and no attention state", () => {
it("renders '2 subagents' when two rows are not running", () => {
render([row({ id: "child-a" }), row({ id: "child-b" })]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("2 subagents");
});
@@ -228,14 +228,12 @@ describe("SubagentsSection", () => {
);
});
it("renders '1 subagent · 1 needs attention' for a single attention row", () => {
it("renders '1 subagent' for a finished row that still requires attention upstream", () => {
render([row({ id: "child-a", requiresAttention: true })]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe(
"1 subagent · 1 needs attention",
);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("1 subagent");
});
it("renders '5 subagents · 2 running · 1 needs attention' when both suffixes apply", () => {
it("renders '5 subagents · 2 running' when finished rows require attention upstream", () => {
render([
row({ id: "a", status: "running" }),
row({ id: "b", status: "running" }),
@@ -244,31 +242,27 @@ describe("SubagentsSection", () => {
row({ id: "e" }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe(
"5 subagents · 2 running · 1 needs attention",
"5 subagents · 2 running",
);
});
});
it("counts attention only from amber (idle + requiresAttention) rows", () => {
it("ignores requiresAttention for non-running header copy", () => {
render([
row({ id: "a", status: "error", requiresAttention: false }),
row({ id: "b", status: "idle", requiresAttention: false }),
row({ id: "c", status: "idle", requiresAttention: true }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe(
"3 subagents · 1 needs attention",
);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("3 subagents");
});
it("excludes errored or running rows from the needs attention count", () => {
it("still counts running rows even when they require attention", () => {
render([
row({ id: "a", status: "error", requiresAttention: true }),
row({ id: "b", status: "running", requiresAttention: true }),
row({ id: "c", status: "idle", requiresAttention: true }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe(
"3 subagents · 1 running · 1 needs attention",
);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("3 subagents · 1 running");
});
it("renders each row through the shared workspace tab icon primitives", () => {
+1 -12
View File
@@ -33,27 +33,16 @@ const SUBAGENTS_LIST_MAX_HEIGHT = 200;
function formatHeaderLabel(rows: SubagentRow[]): string {
let runningCount = 0;
let attentionCount = 0;
for (const row of rows) {
if (row.status === "running") {
runningCount += 1;
}
const bucket = deriveSidebarStateBucket({
status: row.status,
requiresAttention: row.requiresAttention,
});
if (bucket === "attention") {
attentionCount += 1;
}
}
const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`];
if (runningCount > 0) {
parts.push(`${runningCount} running`);
}
if (attentionCount > 0) {
parts.push(`${attentionCount} needs attention`);
}
return parts.join(" · ");
}
@@ -82,7 +71,7 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
icon: getProviderIcon(row.provider),
statusBucket: deriveSidebarStateBucket({
status: row.status,
requiresAttention: row.requiresAttention,
requiresAttention: false,
}),
};
}
+125 -40
View File
@@ -5,6 +5,7 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
const SERVER_ID = "server-1";
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
const EMPTY_PENDING_ARCHIVE_IDS = new Set<string>();
const AGENT_DEFAULTS: Agent = {
serverId: SERVER_ID,
@@ -71,10 +72,14 @@ describe("selectSubagentsForParent", () => {
}),
]);
const rows = selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-a",
});
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
@@ -89,10 +94,14 @@ describe("selectSubagentsForParent", () => {
makeAgent({ id: "unrelated" }),
]);
const rows = selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-a",
});
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
@@ -104,14 +113,22 @@ describe("selectSubagentsForParent", () => {
makeAgent({ id: "grandchild", parentAgentId: "child" }),
]);
const parentRows = selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent",
});
const childRows = selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "child",
});
const parentRows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
const childRows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "child",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(parentRows.map((row) => row.id)).toEqual(["child"]);
expect(childRows.map((row) => row.id)).toEqual(["grandchild"]);
@@ -137,10 +154,14 @@ describe("selectSubagentsForParent", () => {
}),
]);
const rows = selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent",
});
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["first", "second", "third"]);
});
@@ -162,10 +183,14 @@ describe("selectSubagentsForParent", () => {
}),
]);
const rows = selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent",
});
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows).toEqual([
{
@@ -195,16 +220,24 @@ describe("selectSubagentsForParent", () => {
setAgents([makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), child]);
expect(
selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-a",
}).map((row) => row.id),
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual(["child"]);
expect(
selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-b",
}).map((row) => row.id),
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-b",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual([]);
setAgents([
@@ -214,16 +247,68 @@ describe("selectSubagentsForParent", () => {
]);
expect(
selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-a",
}).map((row) => row.id),
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual([]);
expect(
selectSubagentsForParent(useSessionStore.getState(), {
serverId: SERVER_ID,
parentAgentId: "parent-b",
}).map((row) => row.id),
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-b",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual(["child"]);
});
it("excludes children whose archive is pending", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child-a", parentAgentId: "parent" }),
makeAgent({ id: "child-b", parentAgentId: "parent" }),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
new Set(["child-b"]),
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("returns the shared empty array when pending archive hides the last child", () => {
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child", parentAgentId: "parent" })]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
new Set(["child"]),
);
expect(rows).toEqual([]);
expect(rows).toBe(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "missing-parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
),
);
});
});
+9 -2
View File
@@ -1,3 +1,4 @@
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -34,6 +35,7 @@ function toSubagentRow(agent: Agent): SubagentRow {
export function selectSubagentsForParent(
state: SessionStoreSnapshot,
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): SubagentRow[] {
const agents = state.sessions[params.serverId]?.agents;
if (!agents || agents.size === 0) {
@@ -42,7 +44,11 @@ export function selectSubagentsForParent(
const rows: SubagentRow[] = [];
for (const agent of agents.values()) {
if (agent.archivedAt || agent.parentAgentId !== params.parentAgentId) {
if (
agent.archivedAt ||
pendingArchiveIds.has(agent.id) ||
agent.parentAgentId !== params.parentAgentId
) {
continue;
}
rows.push(toSubagentRow(agent));
@@ -57,9 +63,10 @@ export function selectSubagentsForParent(
}
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectSubagentsForParent(state, params),
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
equal,
);
}