From a9fc993586322d84fe704ef9f4c7be26ebd80920 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:59:38 +0200 Subject: [PATCH 1/4] fix(web): resolve subagent tooltip models from child threads --- .../chat/SubagentTooltipContent.test.tsx | 63 +++++++++++++++++++ .../chat/SubagentTooltipContent.tsx | 18 ++++-- .../chat/ThreadRelationshipsControl.tsx | 2 + .../src/components/chat/V2LifecycleRow.tsx | 1 + 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/chat/SubagentTooltipContent.test.tsx diff --git a/apps/web/src/components/chat/SubagentTooltipContent.test.tsx b/apps/web/src/components/chat/SubagentTooltipContent.test.tsx new file mode 100644 index 000000000000..1b86e670defc --- /dev/null +++ b/apps/web/src/components/chat/SubagentTooltipContent.test.tsx @@ -0,0 +1,63 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { SubagentTooltipContent } from "./SubagentTooltipContent"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-18T00:00:00.000Z", + models: [ + { + slug: "gpt-5.4", + name: "OpenAI GPT-5.4", + shortName: "OpenAI GPT-5.4", + subProvider: "OpenAI", + aliases: ["default-model"], + isCustom: false, + capabilities: {}, + }, + ], + slashCommands: [], + skills: [], +}; + +afterEach(() => vi.unstubAllGlobals()); + +it.each([ + [null, true, "GPT-5.4"], + ["", true, "GPT-5.4"], + [" ", true, "GPT-5.4"], + ["default-model", false, "GPT-5.4"], + ["gpt-5.4", false, "GPT-5.4"], + ["gpt-5.5", true, "GPT-5.5"], + ["custom/model-v1", true, "custom/model-v1"], + [null, false, "Not reported"], +] as const)("displays model %s with child=%s as %s", async (model, hasChild, expected) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + try { + expect(renderer.root.findAllByType("dd")[0]?.children.join("")).toBe(expected); + } finally { + await act(async () => renderer.unmount()); + } +}); diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index 83f9026b6906..8900f0f715d1 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -4,7 +4,7 @@ import type { ServerProvider, } from "@t3tools/contracts"; import { fileBasename } from "@t3tools/client-runtime/markdown-links"; -import { formatModelSlugName } from "@t3tools/shared/model"; +import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; import { getTriggerDisplayModelName } from "./providerIconUtils"; import { Fragment } from "react"; @@ -14,19 +14,25 @@ export function SubagentTooltipContent(props: { model: string | null; provider?: ServerProvider | undefined; parentThread?: Pick | undefined; - childThread?: Pick | undefined; + childThread?: + | Pick + | undefined; parentProject?: Pick | undefined; childProject?: Pick | undefined; status: string; result?: string | null | undefined; progress?: string | null | undefined; }) { - const providerModel = props.provider?.models.find((candidate) => candidate.slug === props.model); + const model = props.model?.trim() || props.childThread?.modelSelection.model.trim(); + const modelSlug = props.provider + ? resolveSelectableModel(props.provider.driver, model, props.provider.models) + : model; + const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); const modelLabel = providerModel ? getTriggerDisplayModelName(providerModel) - : props.model - ? formatModelSlugName(props.model) - : "Unknown"; + : model + ? formatModelSlugName(model) + : "Not reported"; const currentWorkspace = props.parentThread?.worktreePath ?? props.parentProject?.workspaceRoot; const childWorkspace = props.childThread?.worktreePath ?? props.childProject?.workspaceRoot; const metadata = [ diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 827a7697294a..ad8923941054 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -389,6 +389,7 @@ export function ThreadRelationshipsPanel(props: {
Date: Fri, 18 Sep 2026 20:01:15 +0200 Subject: [PATCH 2/4] feat(web): show running subagent count in Lineage --- .../chat/ThreadRelationshipsControl.test.tsx | 52 ++++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 3 +- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx index 2d4ea8cc1f56..bad96fe51ed8 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx @@ -1,7 +1,26 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { renderToStaticMarkup } from "react-dom/server"; +import { ThreadId } from "@t3tools/contracts"; -import { resolveThreadLineageWindow, ThreadLineageRowList } from "./ThreadRelationshipsControl"; +import { makeThreadFixture } from "../../test-fixtures"; +import { + resolveThreadLineageWindow, + ThreadLineageRowList, + ThreadRelationshipsPanel, +} from "./ThreadRelationshipsControl"; + +const state = vi.hoisted(() => ({ threads: [] as ReturnType[] })); +vi.mock("../../state/entities", () => ({ + useThreadProjection: () => null, + useThreadShells: () => state.threads, + useProjects: () => [], + useServerConfigs: () => new Map(), +})); +vi.mock("../../lib/archivedThreadsState", () => ({ + useArchivedThreadSnapshots: () => ({ snapshots: [] }), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); const rows = Array.from({ length: 20 }, (_, index) => `row-${index}`); @@ -17,6 +36,35 @@ function renderRowList(visibleCount: number) { } describe("thread lineage row list", () => { + it.each([0, 1, 8])("counts %i running children across all pages", (runningCount) => { + const parent = makeThreadFixture(); + state.threads = [ + parent, + ...Array.from({ length: runningCount + 2 }, (_, index) => { + const child = makeThreadFixture({ + id: ThreadId.make(`child-${index}`), + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: index === runningCount + 1 ? "fork" : "subagent", + }, + }); + return { + ...child, + source: { + ...child.source, + status: index === runningCount ? ("idle" as const) : ("running" as const), + }, + }; + }), + ]; + const markup = renderToStaticMarkup( + , + ); + const heading = /]*>(.*?)<\/h3>/.exec(markup)?.[1]; + expect(heading).toBe(runningCount ? `Lineage · ${runningCount} running` : "Lineage"); + }); + it("shows six rows before the first expansion", () => { const { visibleRows, hiddenCount } = resolveThreadLineageWindow(rows, 6); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index ad8923941054..9b40ce1ea36e 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -240,6 +240,7 @@ export function ThreadRelationshipsPanel(props: { { id: "active", label: null, rows: active, expanded: true }, { id: "previous", label: "Previous agents", rows: previous, expanded: false }, ]; + const runningCount = active.filter(({ edge }) => edge.status === "running").length; if (relationshipRows.length === 0) { return null; @@ -285,7 +286,7 @@ export function ThreadRelationshipsPanel(props: { return ( 0 ? `Lineage · ${runningCount} running` : "Lineage"} data-thread-relationships-panel actions={ canDetach ? ( From 93fb0c95abf39ca560c4ba6ed923bbd1d4fb2a6c Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:09:42 +0200 Subject: [PATCH 3/4] test(web): cover subagent models and counts in existing panel tests --- .../chat/SubagentTooltipContent.test.tsx | 63 ------------------- ...ThreadRelationshipsControl.agents.test.tsx | 37 ++++++++++- .../chat/ThreadRelationshipsControl.test.tsx | 52 +-------------- .../chat/ThreadRelationshipsControl.tsx | 4 +- 4 files changed, 40 insertions(+), 116 deletions(-) delete mode 100644 apps/web/src/components/chat/SubagentTooltipContent.test.tsx diff --git a/apps/web/src/components/chat/SubagentTooltipContent.test.tsx b/apps/web/src/components/chat/SubagentTooltipContent.test.tsx deleted file mode 100644 index 1b86e670defc..000000000000 --- a/apps/web/src/components/chat/SubagentTooltipContent.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { act } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, expect, it, vi } from "vite-plus/test"; - -import { makeThreadProjectionFixture } from "../../test-fixtures"; -import { SubagentTooltipContent } from "./SubagentTooltipContent"; - -const provider: ServerProvider = { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-18T00:00:00.000Z", - models: [ - { - slug: "gpt-5.4", - name: "OpenAI GPT-5.4", - shortName: "OpenAI GPT-5.4", - subProvider: "OpenAI", - aliases: ["default-model"], - isCustom: false, - capabilities: {}, - }, - ], - slashCommands: [], - skills: [], -}; - -afterEach(() => vi.unstubAllGlobals()); - -it.each([ - [null, true, "GPT-5.4"], - ["", true, "GPT-5.4"], - [" ", true, "GPT-5.4"], - ["default-model", false, "GPT-5.4"], - ["gpt-5.4", false, "GPT-5.4"], - ["gpt-5.5", true, "GPT-5.5"], - ["custom/model-v1", true, "custom/model-v1"], - [null, false, "Not reported"], -] as const)("displays model %s with child=%s as %s", async (model, hasChild, expected) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let renderer!: ReactTestRenderer; - await act(async () => { - renderer = create( - , - ); - }); - try { - expect(renderer.root.findAllByType("dd")[0]?.children.join("")).toBe(expected); - } finally { - await act(async () => renderer.unmount()); - } -}); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index 8549f452ef4e..a0ccd67e66ed 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -94,6 +94,7 @@ it("shows the matching child agent details and refreshes them when the agent set .join(" ") .replace(/\s+/g, " "); expect(text()).toContain("Checker"); + expect(text()).toContain("Lineage · 3 running"); expect(text()).toContain("running"); expect(text()).not.toContain("gpt-5.4"); expect(text()).not.toContain("gpt-5.3"); @@ -115,6 +116,7 @@ it("shows the matching child agent details and refreshes them when the agent set ], }; await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]); expect(text()).toContain("Previous agents (1)"); expect(text()).not.toContain("Checker"); await act(async () => @@ -136,6 +138,17 @@ it("shows the matching child agent details and refreshes them when the agent set ); expect(text()).toContain("Checker"); + state.projection = { + ...projection, + subagents: Array.from({ length: 8 }, (_, index) => ({ + ...agent, + id: `running-agent-${index}`, + childThreadId: `running-child-${index}`, + })), + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).toContain("Lineage · 8 running"); + state.projection = { ...projection, subagents: Array.from({ length: 8 }, (_, index) => ({ @@ -176,6 +189,7 @@ it("shows readable models and only differing workspace details in agent tooltips worktreePath: null as string | null, branch: null as string | null, title: "Worker", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, }; state.projects = [ @@ -194,7 +208,9 @@ it("shows readable models and only differing workspace details in agent tooltips { instanceId: "codex", driver: "codex", - models: [{ slug: "gpt-5.4", name: "My GPT model", shortName: "My GPT" }], + models: [ + { slug: "gpt-5.4", name: "My GPT model", shortName: "My GPT", aliases: ["model-alias"] }, + ], }, ], }); @@ -242,6 +258,23 @@ it("shows readable models and only differing workspace details in agent tooltips expect(text()).not.toContain("Worktree"); expect(text()).not.toContain("Workspace"); + for (const [model, expected] of [ + [null, "My GPT"], + ["", "My GPT"], + [" ", "My GPT"], + ["model-alias", "My GPT"], + ["gpt-5.5", "GPT-5.5"], + ["custom/model-v1", "custom/model-v1"], + ] as const) { + state.projection = { + ...projection, + subagents: [{ ...projection.subagents[0], model }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).toContain(expected); + expect(text()).not.toContain("Unknown"); + } + state.projection = { ...projection, subagents: [ @@ -317,7 +350,7 @@ it("shows readable models and only differing workspace details in agent tooltips ["grok", "grok-4-fast", "Grok 4 Fast"], ["antigravity", "gemini-3.8-flash-high", "Gemini 3.8 Flash High"], ["opencode", "anthropic/claude-sonnet-4-6", "anthropic/Claude Sonnet 4.6"], - ["codex", null, "Unknown"], + ["codex", null, "Not reported"], ] as const) { state.projection = { ...projection, diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx index bad96fe51ed8..2d4ea8cc1f56 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.test.tsx @@ -1,26 +1,7 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { renderToStaticMarkup } from "react-dom/server"; -import { ThreadId } from "@t3tools/contracts"; -import { makeThreadFixture } from "../../test-fixtures"; -import { - resolveThreadLineageWindow, - ThreadLineageRowList, - ThreadRelationshipsPanel, -} from "./ThreadRelationshipsControl"; - -const state = vi.hoisted(() => ({ threads: [] as ReturnType[] })); -vi.mock("../../state/entities", () => ({ - useThreadProjection: () => null, - useThreadShells: () => state.threads, - useProjects: () => [], - useServerConfigs: () => new Map(), -})); -vi.mock("../../lib/archivedThreadsState", () => ({ - useArchivedThreadSnapshots: () => ({ snapshots: [] }), -})); -vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +import { resolveThreadLineageWindow, ThreadLineageRowList } from "./ThreadRelationshipsControl"; const rows = Array.from({ length: 20 }, (_, index) => `row-${index}`); @@ -36,35 +17,6 @@ function renderRowList(visibleCount: number) { } describe("thread lineage row list", () => { - it.each([0, 1, 8])("counts %i running children across all pages", (runningCount) => { - const parent = makeThreadFixture(); - state.threads = [ - parent, - ...Array.from({ length: runningCount + 2 }, (_, index) => { - const child = makeThreadFixture({ - id: ThreadId.make(`child-${index}`), - lineage: { - rootThreadId: parent.id, - parentThreadId: parent.id, - relationshipToParent: index === runningCount + 1 ? "fork" : "subagent", - }, - }); - return { - ...child, - source: { - ...child.source, - status: index === runningCount ? ("idle" as const) : ("running" as const), - }, - }; - }), - ]; - const markup = renderToStaticMarkup( - , - ); - const heading = /]*>(.*?)<\/h3>/.exec(markup)?.[1]; - expect(heading).toBe(runningCount ? `Lineage · ${runningCount} running` : "Lineage"); - }); - it("shows six rows before the first expansion", () => { const { visibleRows, hiddenCount } = resolveThreadLineageWindow(rows, 6); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 9b40ce1ea36e..6d91c8fdc3a9 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -240,7 +240,9 @@ export function ThreadRelationshipsPanel(props: { { id: "active", label: null, rows: active, expanded: true }, { id: "previous", label: "Previous agents", rows: previous, expanded: false }, ]; - const runningCount = active.filter(({ edge }) => edge.status === "running").length; + const runningCount = + projection?.subagents.filter((agent) => agent.status === "running").length ?? + active.filter(({ edge }) => edge.status === "running").length; if (relationshipRows.length === 0) { return null; From ebefc29b268b079df74b11c54684717b81a8a72e Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:11:28 +0200 Subject: [PATCH 4/4] fix(web): keep running count visible before child threads arrive --- .../chat/ThreadRelationshipsControl.agents.test.tsx | 7 +++++++ .../web/src/components/chat/ThreadRelationshipsControl.tsx | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index a0ccd67e66ed..1e7dd8fc0436 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -171,6 +171,13 @@ it("shows the matching child agent details and refreshes them when the agent set .props.onClick(), ); expect(text()).toContain("Old agent 7"); + + state.projection = { + ...projection, + subagents: [{ ...agent, childThreadId: null }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).toContain("Lineage · 1 running"); }); it("shows readable models and only differing workspace details in agent tooltips", async () => { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 6d91c8fdc3a9..b07013bea588 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -244,7 +244,7 @@ export function ThreadRelationshipsPanel(props: { projection?.subagents.filter((agent) => agent.status === "running").length ?? active.filter(({ edge }) => edge.status === "running").length; - if (relationshipRows.length === 0) { + if (relationshipRows.length === 0 && runningCount === 0) { return null; }