diff --git a/apps/web/src/components/AgentThreadTree.logic.test.ts b/apps/web/src/components/AgentThreadTree.logic.test.ts new file mode 100644 index 0000000000..52ae317a3d --- /dev/null +++ b/apps/web/src/components/AgentThreadTree.logic.test.ts @@ -0,0 +1,167 @@ +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + assignAgentSections, + childThreadsByParent, + parseRouterJobTag, + routerJobTagOfMessages, + threadBreadcrumbLinks, + threadShellStatus, + toggleExpandedThread, + type RouterJobTag, +} from "./AgentThreadTree.logic"; + +const PLANNER = "7b0736c2-3dec-4adb-aa18-6d93d15db108"; + +function agent(id: string, status: RuntimeSubagent["status"] = "running"): RuntimeSubagent { + return { id, title: id, status } as RuntimeSubagent; +} + +function shell(id: string, createdAt: string, extra: { archivedAt?: string | null } = {}) { + return { id, title: `title ${id}`, createdAt, archivedAt: extra.archivedAt ?? null }; +} + +describe("router job tags", () => { + it("reads the tag Model Router opens every job thread with", () => { + expect( + parseRouterJobTag( + `[model-router job router-80-release-t3 worker seq 2 on route muse-spark-xhigh-free; planner thread ${PLANNER}]\n\nThis turn belongs to ...`, + ), + ).toEqual({ + requestId: "router-80-release-t3", + kind: "worker seq 2", + route: "muse-spark-xhigh-free", + plannerThreadId: PLANNER, + }); + }); + + it("ignores user prompts that merely mention a job", () => { + expect( + parseRouterJobTag("Implement Issue #17. [model-router job x y on route z; planner thread p]"), + ).toBeNull(); + expect( + routerJobTagOfMessages([ + { + role: "assistant", + text: `[model-router job a dispatcher on route luna/max; planner thread ${PLANNER}]`, + }, + { role: "user", text: "Spawned by spawn_thread" }, + ]), + ).toBeNull(); + }); +}); + +describe("section assignment", () => { + const dispatcherTag: RouterJobTag = { + requestId: "job-1", + kind: "dispatcher", + route: "luna/max", + plannerThreadId: PLANNER, + }; + const workerTag: RouterJobTag = { ...dispatcherTag, kind: "worker seq 1", route: "muse-free" }; + + it("keeps own spawns direct and groups router threads by job with the dispatcher route", () => { + const sections = assignAgentSections( + [ + agent("native-subagent"), + agent(`sub.${PLANNER}.w1`, "idle"), + agent(`sub.${PLANNER}.own`), + agent(`sub.${PLANNER}.d1`, "failed"), + agent(`sub.${PLANNER}.other`, "completed"), + ], + new Map([ + [`sub.${PLANNER}.w1`, workerTag], + [`sub.${PLANNER}.own`, null], + [`sub.${PLANNER}.d1`, dispatcherTag], + [`sub.${PLANNER}.other`, { ...dispatcherTag, requestId: "job-2" }], + ]), + ); + expect(sections.directAgents.map((a) => a.id)).toEqual([ + "native-subagent", + `sub.${PLANNER}.own`, + ]); + expect(sections.prismJobs.map((job) => [job.requestId, job.route, job.status])).toEqual([ + ["job-1", "luna/max", "failed"], + ["job-2", "luna/max", "completed"], + ]); + expect(sections.prismJobs[0]!.agents.map((a) => a.id)).toEqual([ + `sub.${PLANNER}.w1`, + `sub.${PLANNER}.d1`, + ]); + }); + + it("leaves not-yet-read threads in Direct spawns", () => { + const sections = assignAgentSections([agent(`sub.${PLANNER}.w1`)], new Map()); + expect(sections.directAgents).toHaveLength(1); + expect(sections.prismJobs).toEqual([]); + }); +}); + +describe("child thread tree", () => { + it("nests live children under their parent in spawn order", () => { + const children = childThreadsByParent([ + shell(PLANNER, "2026-09-25T10:00:00Z"), + shell(`sub.${PLANNER}.b`, "2026-09-25T10:02:00Z"), + shell(`sub.${PLANNER}.a`, "2026-09-25T10:01:00Z"), + shell(`sub.${PLANNER}.gone`, "2026-09-25T10:03:00Z", { archivedAt: "2026-09-25T11:00:00Z" }), + shell(`sub.sub.${PLANNER}.a.x`, "2026-09-25T10:04:00Z"), + ]); + expect(children.get(PLANNER)?.map((s) => s.id)).toEqual([ + `sub.${PLANNER}.a`, + `sub.${PLANNER}.b`, + ]); + expect(children.get(`sub.${PLANNER}.a`)?.map((s) => s.id)).toEqual([`sub.sub.${PLANNER}.a.x`]); + }); + + it("starts collapsed and toggles only the clicked row", () => { + let expanded: ReadonlySet = new Set(); + expanded = toggleExpandedThread(expanded, "a"); + expanded = toggleExpandedThread(expanded, "b"); + expect([...expanded]).toEqual(["a", "b"]); + expanded = toggleExpandedThread(expanded, "a"); + expect([...expanded]).toEqual(["b"]); + }); + + it("reads a shell's status like the Agents panel does", () => { + expect(threadShellStatus({ session: null, latestTurn: null })).toBe("pending"); + expect(threadShellStatus({ session: null, latestTurn: { state: "completed" } } as never)).toBe( + "idle", + ); + expect(threadShellStatus({ session: { status: "running" }, latestTurn: null } as never)).toBe( + "running", + ); + expect(threadShellStatus({ session: null, latestTurn: { state: "error" } } as never)).toBe( + "failed", + ); + }); +}); + +describe("breadcrumb links", () => { + const shells = [ + shell(PLANNER, "2026-09-25T10:00:00Z"), + shell(`sub.${PLANNER}.a`, "2026-09-25T10:01:00Z"), + shell(`sub.${PLANNER}.b`, "2026-09-25T10:02:00Z"), + ]; + + it("links a child thread to its parent and siblings", () => { + const links = threadBreadcrumbLinks(`sub.${PLANNER}.b`, shells); + expect(links?.parent.id).toBe(PLANNER); + expect(links?.siblings.map((s) => s.id)).toEqual([`sub.${PLANNER}.a`, `sub.${PLANNER}.b`]); + }); + + it("prefers a real parentThreadId over the id convention", () => { + const links = threadBreadcrumbLinks("child", [ + ...shells, + { ...shell("child", "2026-09-25T10:05:00Z"), parentThreadId: PLANNER }, + ]); + expect(links?.parent.id).toBe(PLANNER); + }); + + it("shows no crumbs for user threads or children whose parent is gone", () => { + expect(threadBreadcrumbLinks(PLANNER, shells)).toBeNull(); + expect( + threadBreadcrumbLinks("sub.missing.a", [shell("sub.missing.a", "2026-09-25T10:00:00Z")]), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/AgentThreadTree.logic.ts b/apps/web/src/components/AgentThreadTree.logic.ts new file mode 100644 index 0000000000..f1f705c7b6 --- /dev/null +++ b/apps/web/src/components/AgentThreadTree.logic.ts @@ -0,0 +1,192 @@ +/** + * Agents panel structure for child threads (toolboxmd/t3code#17): which + * section a spawn belongs to, which threads nest under a row, and the + * parent/sibling links for a child thread's header breadcrumb. + * + * Pure functions over thread shells and the subagent fold, so every client + * can reuse them and tests need no rendering. + */ +import type { + RuntimeSubagent, + RuntimeSubagentStatus, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +import { parentThreadIdOf } from "./subagentThreads"; + +/** + * Model Router (planned name Prism) opens every job thread with + * `[model-router job on route ; planner thread ]`. + */ +export interface RouterJobTag { + readonly requestId: string; + readonly kind: string; + readonly route: string; + readonly plannerThreadId: string; +} + +const ROUTER_JOB_TAG = /^\[model-router job (\S+) (.+?) on route (\S+); planner thread (\S+?)\]/; + +export function parseRouterJobTag(text: string): RouterJobTag | null { + const match = ROUTER_JOB_TAG.exec(text.trimStart()); + if (!match) return null; + const [, requestId, kind, route, plannerThreadId] = match; + return { requestId: requestId!, kind: kind!, route: route!, plannerThreadId: plannerThreadId! }; +} + +/** Recognizes a router thread from its loaded user messages, oldest first. */ +export function routerJobTagOfMessages( + messages: ReadonlyArray<{ readonly role: string; readonly text: string }>, +): RouterJobTag | null { + for (const message of messages) { + if (message.role !== "user") continue; + const tag = parseRouterJobTag(message.text); + if (tag) return tag; + } + return null; +} + +/** Threads carry their parent in the `sub..` id; a real field wins. */ +function parentThreadIdOfShell(shell: { + readonly id: string; + readonly parentThreadId?: string | null; +}): string | null { + return shell.parentThreadId ?? parentThreadIdOf(shell.id); +} + +interface TreeShell { + readonly id: string; + readonly createdAt: string; + readonly archivedAt: string | null; + readonly parentThreadId?: string | null; +} + +/** Live child threads per parent id, in spawn order. */ +export function childThreadsByParent( + shells: ReadonlyArray, +): ReadonlyMap> { + const byParent = new Map(); + for (const shell of shells) { + if (shell.archivedAt !== null) continue; + const parentId = parentThreadIdOfShell(shell); + if (parentId === null) continue; + const siblings = byParent.get(parentId); + if (siblings) siblings.push(shell); + else byParent.set(parentId, [shell]); + } + for (const siblings of byParent.values()) { + siblings.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + return byParent; +} + +export interface PrismJobGroup { + readonly requestId: string; + readonly route: string; + readonly status: RuntimeSubagentStatus; + readonly agents: ReadonlyArray; +} + +export interface AgentSections { + readonly directAgents: ReadonlyArray; + readonly prismJobs: ReadonlyArray; +} + +// Most urgent first: a job reads as working while any thread works. +const JOB_STATUS_PRECEDENCE: ReadonlyArray = [ + "running", + "waiting", + "pending", + "failed", + "interrupted", + "cancelled", + "idle", + "completed", +]; + +function jobStatusOf(agents: ReadonlyArray): RuntimeSubagentStatus { + for (const status of JOB_STATUS_PRECEDENCE) { + if (agents.some((agent) => agent.status === status)) return status; + } + return "pending"; +} + +/** + * Splits the panel's direct spawns: threads Model Router started for this + * thread go to "Prism spawns", grouped by job in first-seen order; everything + * this thread started itself stays in "Direct spawns". + */ +export function assignAgentSections( + directAgents: ReadonlyArray, + routerTags: ReadonlyMap, +): AgentSections { + const direct: RuntimeSubagent[] = []; + const jobs = new Map(); + for (const agent of directAgents) { + const tag = routerTags.get(agent.id) ?? null; + if (tag === null) { + direct.push(agent); + continue; + } + const job = jobs.get(tag.requestId); + if (job) { + job.tags.push(tag); + job.agents.push(agent); + } else { + jobs.set(tag.requestId, { tags: [tag], agents: [agent] }); + } + } + return { + directAgents: direct, + prismJobs: [...jobs].map(([requestId, job]) => ({ + requestId, + route: (job.tags.find((tag) => tag.kind === "dispatcher") ?? job.tags[0]!).route, + status: jobStatusOf(job.agents), + agents: job.agents, + })), + }; +} + +/** Coarse Agents-panel status for a thread known only by its shell. */ +export function threadShellStatus( + shell: Pick, +): RuntimeSubagentStatus { + const session = shell.session?.status; + const turn = shell.latestTurn?.state; + if (session === "running" || session === "starting" || turn === "running") return "running"; + if (session === "error" || turn === "error") return "failed"; + if (session === "interrupted" || turn === "interrupted") return "interrupted"; + if (turn === "completed") return "idle"; + return "pending"; +} + +/** Toggles one row's children; nothing else changes, so nothing opens on its own. */ +export function toggleExpandedThread( + expanded: ReadonlySet, + threadId: string, +): ReadonlySet { + const next = new Set(expanded); + if (!next.delete(threadId)) next.add(threadId); + return next; +} + +export interface ThreadBreadcrumbLinks { + readonly parent: T; + readonly siblings: ReadonlyArray; +} + +/** + * Parent and siblings (including the thread itself) for a child thread's + * header, or null for a thread the user started or whose parent is gone. + */ +export function threadBreadcrumbLinks( + threadId: string, + shells: ReadonlyArray, +): ThreadBreadcrumbLinks | null { + const self = shells.find((shell) => shell.id === threadId); + const parentId = self ? parentThreadIdOfShell(self) : parentThreadIdOf(threadId); + if (parentId === null) return null; + const parent = shells.find((shell) => shell.id === parentId); + if (!parent) return null; + return { parent, siblings: childThreadsByParent(shells).get(parentId) ?? [] }; +} diff --git a/apps/web/src/components/AgentThreadTree.tsx b/apps/web/src/components/AgentThreadTree.tsx new file mode 100644 index 0000000000..3c8c124055 --- /dev/null +++ b/apps/web/src/components/AgentThreadTree.tsx @@ -0,0 +1,232 @@ +/** + * Agents panel tree for child threads (toolboxmd/t3code#17): the Prism job + * tags, the per-row child-count chevron, and the compact nested rows. + */ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + formatSubagentModelLabel, + type RuntimeSubagent, + type RuntimeSubagentStatus, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useEffect, useMemo, type ReactNode } from "react"; +import { create } from "zustand"; + +import { cn } from "~/lib/utils"; +import { useThreadDetail, useThreadShells } from "~/state/entities"; +import { AgentThreadLink } from "./AgentThreadLink"; +import { + assignAgentSections, + childThreadsByParent, + routerJobTagOfMessages, + threadShellStatus, + type AgentSections, + type RouterJobTag, +} from "./AgentThreadTree.logic"; +import { isSubagentThreadId } from "./subagentThreads"; + +/** + * Router tags never change once a thread's first message lands, so each + * child thread is read once per session and then released. + */ +const useRouterJobTagStore = create<{ + readonly tags: Readonly>; + readonly record: (key: string, tag: RouterJobTag | null) => void; +}>()((set) => ({ + tags: {}, + record: (key, tag) => set((state) => ({ tags: { ...state.tags, [key]: tag } })), +})); + +const tagKey = (environmentId: EnvironmentId, threadId: string) => + `${environmentId}\u0000${threadId}`; + +function RouterJobTagProbe({ + environmentId, + threadId, +}: { + environmentId: EnvironmentId; + threadId: string; +}) { + const detail = useThreadDetail(scopeThreadRef(environmentId, threadId as ThreadId)); + const record = useRouterJobTagStore((state) => state.record); + useEffect(() => { + // A thread with no user message yet may still receive its tag. + if (!detail?.messages.some((message) => message.role === "user")) return; + record(tagKey(environmentId, threadId), routerJobTagOfMessages(detail.messages)); + }, [detail, environmentId, record, threadId]); + return null; +} + +export interface AgentThreadTree { + readonly sections: AgentSections; + readonly childrenByParent: ReadonlyMap>; + /** Mount once so unknown child threads get their router tag read. */ + readonly probes: ReactNode; +} + +export function useAgentThreadTree( + environmentId: EnvironmentId | null, + directAgents: ReadonlyArray, +): AgentThreadTree { + const shells = useThreadShells(); + const tags = useRouterJobTagStore((state) => state.tags); + const childrenByParent = useMemo( + () => childThreadsByParent(shells.filter((shell) => shell.environmentId === environmentId)), + [environmentId, shells], + ); + return useMemo(() => { + const known = new Map(); + const unknown: string[] = []; + if (environmentId !== null) { + for (const agent of directAgents) { + if (!isSubagentThreadId(agent.id)) continue; + const key = tagKey(environmentId, agent.id); + if (key in tags) known.set(agent.id, tags[key] ?? null); + else unknown.push(agent.id); + } + } + return { + sections: assignAgentSections(directAgents, known), + childrenByParent, + probes: + environmentId === null + ? null + : unknown.map((threadId) => ( + + )), + }; + }, [childrenByParent, directAgents, environmentId, tags]); +} + +function ChildThreadToggle({ + count, + open, + onToggle, +}: { + count: number; + open: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +const COMPACT_DOT_CLASS: Record = { + pending: "bg-info", + running: "bg-info", + waiting: "bg-info", + idle: "bg-muted-foreground/50", + completed: "bg-success", + failed: "bg-destructive", + cancelled: "bg-muted-foreground/60", + interrupted: "bg-muted-foreground/60", +}; + +function shellModelLabel(shell: EnvironmentThreadShell): string | null { + const effort = shell.modelSelection.options?.find((option) => + ["effort", "reasoningEffort", "variant"].includes(option.id), + )?.value; + return formatSubagentModelLabel( + shell.modelSelection.model, + typeof effort === "string" ? effort : null, + ); +} + +interface TreeProps { + environmentId: EnvironmentId | null; + childrenByParent: ReadonlyMap>; + expanded: ReadonlySet; + onToggle: (threadId: string) => void; +} + +/** Compact one-line rows for a thread's children, each with its own chevron. */ +function ChildThreadRows({ + parentId, + depth, + ...tree +}: TreeProps & { parentId: string; depth: number }) { + const children = tree.childrenByParent.get(parentId) ?? []; + return children.map((child) => { + const grandchildren = tree.childrenByParent.get(child.id)?.length ?? 0; + const open = tree.expanded.has(child.id); + const status = threadShellStatus(child); + return ( +
+
+ + + {child.title} + + {shellModelLabel(child)} + + + {grandchildren > 0 ? ( + tree.onToggle(child.id)} + /> + ) : null} +
+ {open ? : null} +
+ ); + }); +} + +/** An agent row that opens its thread and can reveal that thread's children. */ +export function AgentThreadEntry({ + agentId, + children, + ...tree +}: TreeProps & { agentId: string; children: ReactNode }) { + const childCount = tree.childrenByParent.get(agentId)?.length ?? 0; + const open = tree.expanded.has(agentId); + return ( +
+
+
+ + {children} + +
+ {childCount > 0 ? ( +
+ tree.onToggle(agentId)} + /> +
+ ) : null} +
+ {open ? : null} +
+ ); +} diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 8dd3f0956f..70c1687e33 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -25,7 +25,8 @@ import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -import { AgentThreadLink } from "~/components/AgentThreadLink"; +import { AgentThreadEntry, useAgentThreadTree } from "~/components/AgentThreadTree"; +import { toggleExpandedThread } from "~/components/AgentThreadTree.logic"; import { orchestrationEnvironment } from "~/state/orchestration"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Button } from "~/components/ui/button"; @@ -531,6 +532,15 @@ export function AgentsPanel({ environmentId?: EnvironmentId | null; threadId?: ThreadId | null; }) { + const tree = useAgentThreadTree(environmentId, model.directAgents); + const [expanded, setExpanded] = useState>(() => new Set()); + const treeProps = { + environmentId, + childrenByParent: tree.childrenByParent, + expanded, + onToggle: (id: string) => setExpanded((current) => toggleExpandedThread(current, id)), + }; + if (!model.hasAgents) { return (
@@ -546,6 +556,7 @@ export function AgentsPanel({ return (
+ {tree.probes}
{model.workflows.map((group) => ( @@ -556,15 +567,39 @@ export function AgentsPanel({ threadId={threadId} /> ))} - {model.directAgents.length > 0 ? ( + {tree.sections.directAgents.length > 0 ? (
Direct spawns
- {model.directAgents.map((agent) => ( - + {tree.sections.directAgents.map((agent) => ( + - + + ))} +
+ ) : null} + {tree.sections.prismJobs.length > 0 ? ( +
+
+ Prism spawns +
+ {tree.sections.prismJobs.map((job) => ( +
+
+ + {job.requestId} + + {job.status === "idle" ? "Idle" : STATUS_VISUALS[job.status].label} + + {job.route} +
+ {job.agents.map((agent) => ( + + + + ))} +
))}
) : null} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d03b7b613e..5f8d8b0f0d 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -33,6 +33,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; +import { ThreadParentCrumbs } from "./ThreadParentCrumbs"; import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; @@ -434,6 +435,7 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} + {renamingTitle !== null ? ( + threadBreadcrumbLinks( + threadId, + shells.filter((shell) => shell.environmentId === environmentId), + ), + [environmentId, shells, threadId], + ); + if (links === null) return null; + const siblings = links.siblings.filter((sibling) => sibling.id !== threadId); + return ( + <> + + + + {links.parent.title} + + + {siblings.length > 0 ? ( + + + } + > + + + + {siblings.map((sibling) => ( + + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: sibling.id }, + }) + } + > + {sibling.title} + + ))} + + + ) : null} + + + / + + + ); +} diff --git a/docs/fork.md b/docs/fork.md index 39e199336c..4c8bde4c1e 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -70,6 +70,12 @@ any modified upstream file missing from `scripts/fork-upstream-edits.txt`. links agents to their child threads; `apps/web/src/components/Sidebar.tsx` and `apps/web/src/components/LegacySidebar.tsx` hide child threads from the sidebar. +- Agents panel sections, child tree and breadcrumb (#17): + `apps/web/src/components/AgentsPanel.tsx` splits Prism spawns from direct + spawns and nests child threads under their row; + `apps/web/src/components/chat/ChatHeader.tsx` renders the parent crumb and + sibling menu for a child thread. The logic lives in the fork-owned + `AgentThreadTree.logic.ts`, `AgentThreadTree.tsx` and `chat/ThreadParentCrumbs.tsx`. - `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`. - `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 9bedb2a279..bdb7afec50 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -36,6 +36,9 @@ apps/web/src/components/AgentsPanel.tsx apps/web/src/components/LegacySidebar.tsx apps/web/src/components/Sidebar.tsx +# Agents panel sections, child tree and parent breadcrumb (#17) +apps/web/src/components/chat/ChatHeader.tsx + # CI on GitHub-hosted runners (#6) .github/workflows/ci.yml .github/workflows/mobile-fingerprint-check.yml