From 3eda7dd15bd9234f48b21d942b0970c9709fb33f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 16:28:09 +0700 Subject: [PATCH] fix(app): subagents track display + archive flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../app/src/hooks/use-archive-agent.test.ts | 55 +++++- packages/app/src/hooks/use-archive-agent.ts | 53 +++++- .../workspace-subagents-integration.test.ts | 12 +- packages/app/src/subagents/section.test.tsx | 24 +-- packages/app/src/subagents/section.tsx | 13 +- packages/app/src/subagents/select.test.ts | 165 +++++++++++++----- packages/app/src/subagents/select.ts | 11 +- 7 files changed, 253 insertions(+), 80 deletions(-) diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index 977bf5a5a..eeb622efb 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -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 { 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" } }], diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 30a959130..6122893ae 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -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(); export interface ArchiveAgentInput { serverId: string; @@ -67,6 +68,35 @@ function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState { return queryClient.getQueryData(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {}; } +function selectPendingArchiveAgentIds( + pendingState: ArchiveAgentPendingState, + serverId: string, +): ReadonlySet { + 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 => ({}), initialData: {} as ArchiveAgentPendingState, staleTime: Infinity, gcTime: Infinity, }); +} + +export function usePendingArchiveAgentIds(serverId: string): ReadonlySet { + 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, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 49e85c835..37af647b3 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -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"]); }); }); diff --git a/packages/app/src/subagents/section.test.tsx b/packages/app/src/subagents/section.test.tsx index dfc9ecd77..33f68c38e 100644 --- a/packages/app/src/subagents/section.test.tsx +++ b/packages/app/src/subagents/section.test.tsx @@ -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", () => { diff --git a/packages/app/src/subagents/section.tsx b/packages/app/src/subagents/section.tsx index f12f6f72e..bfe9ddb13 100644 --- a/packages/app/src/subagents/section.tsx +++ b/packages/app/src/subagents/section.tsx @@ -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, }), }; } diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index b8b316bb3..9191392c7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -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(); 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, + ), + ); + }); }); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index a58e32e16..08807b689 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -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, ): 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, ); }