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:
@@ -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" } }],
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user