From 4f4b93d18ebdd02a863517b0c26e889d44e1caea Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:42:32 -0400 Subject: [PATCH 01/36] Add the agents panel: the turn's subagents and runs as a thread rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header activity chip now opens a right-panel tree instead of a popover. Each subagent and background run is a branch off a provider-hued trunk: status-ordered (running, waiting, failed, completed), with the task line, freshest output line, and mono meta per branch. Subagents with transcripts drill in to the existing inspector; run branches toggle their terminal and keep a small stop control. The timeline's turn activity row gains a compact per-turn summary (segment bars + "4 subagents · 1 done · 1 needs you") that opens the panel. Plumbing: the panel shares the right-panel slot with source control via an agents=1/0 search param (open panel wins the slot, closing falls back to prior source control state), gets its own 400px width and storage key, and reuses the generalized auto-hide-on-narrow-layout hook. ChatView publishes live turn state through a small bridge store (same shape as the file viewer's) so the route-mounted panel stays in sync. Shared run/subagent display helpers moved from ThreadActivityPopover into threadActivity.ts so the popover, panel, and timeline row read identically. --- apps/web/src/agentsPanelStore.ts | 51 +++ .../ChatRightPanelInlineSidebar.tsx | 29 +- apps/web/src/components/ChatView.browser.tsx | 4 +- apps/web/src/components/ChatView.tsx | 132 ++++++-- apps/web/src/components/RightPanelSheet.tsx | 16 +- .../components/chat/AgentsPanel.browser.tsx | 245 ++++++++++++++ apps/web/src/components/chat/AgentsPanel.tsx | 320 ++++++++++++++++++ .../chat/ChatHeader.render.test.tsx | 4 +- apps/web/src/components/chat/ChatHeader.tsx | 35 +- .../chat/MessagesTimeline.browser.tsx | 74 ++++ .../src/components/chat/MessagesTimeline.tsx | 55 +++ .../src/components/chat/SubagentInspector.tsx | 49 ++- .../chat/ThreadActivityPopover.test.ts | 8 +- .../components/chat/ThreadActivityPopover.tsx | 220 ++++-------- .../components/chat/agentsPanel.logic.test.ts | 303 +++++++++++++++++ .../src/components/chat/agentsPanel.logic.ts | 307 +++++++++++++++++ .../web/src/components/chat/threadActivity.ts | 167 +++++++++ apps/web/src/diffRouteSearch.ts | 94 +++-- apps/web/src/rightPanelLayout.ts | 107 ++++-- .../routes/_chat.$environmentId.$threadId.tsx | 70 +++- apps/web/src/routes/_chat.draft.$draftId.tsx | 15 +- 21 files changed, 1994 insertions(+), 311 deletions(-) create mode 100644 apps/web/src/agentsPanelStore.ts create mode 100644 apps/web/src/components/chat/AgentsPanel.browser.tsx create mode 100644 apps/web/src/components/chat/AgentsPanel.tsx create mode 100644 apps/web/src/components/chat/agentsPanel.logic.test.ts create mode 100644 apps/web/src/components/chat/agentsPanel.logic.ts create mode 100644 apps/web/src/components/chat/threadActivity.ts diff --git a/apps/web/src/agentsPanelStore.ts b/apps/web/src/agentsPanelStore.ts new file mode 100644 index 000000000..4bb657698 --- /dev/null +++ b/apps/web/src/agentsPanelStore.ts @@ -0,0 +1,51 @@ +/** + * Bridge between the chat view, which knows what the current turn is doing, + * and the route, which owns the right-panel slot the agents panel renders in. + * + * The panel needs live subagent progress, background runs and the terminal + * toggle — all of which are chat-view state — but it mounts as a sibling of + * the chat column, next to source control. ChatView publishes here; the route + * reads. Same shape as the file viewer's store, for the same reason. + */ +import { create } from "zustand"; + +import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; + +import type { SubagentProgressItem } from "./session-logic"; +import type { ThreadBackgroundRunItem } from "./components/chat/threadActivity"; + +export interface AgentsPanelSource { + environmentId: EnvironmentId; + threadId: ThreadId; + subagents: ReadonlyArray; + backgroundRuns: ReadonlyArray; + /** Provider driver label, e.g. `codex`; drives the trunk hue and run chips. */ + providerLabel: string | null; + threadCwd: string | null; + onToggleBackgroundRunTerminal: (terminalId: string) => void; + onStopBackgroundRun: (run: ThreadBackgroundRunItem) => void; +} + +interface AgentsPanelStoreState { + source: AgentsPanelSource | null; + publishSource: (source: AgentsPanelSource | null) => void; +} + +export const useAgentsPanelStore = create((set) => ({ + source: null, + publishSource: (source) => { + set({ source }); + }, +})); + +export function useAgentsPanelSource(): AgentsPanelSource | null { + return useAgentsPanelStore((state) => state.source); +} + +export function publishAgentsPanelSource(source: AgentsPanelSource | null): void { + useAgentsPanelStore.getState().publishSource(source); +} + +export function resetAgentsPanelSourceForTests(): void { + useAgentsPanelStore.setState({ source: null }); +} diff --git a/apps/web/src/components/ChatRightPanelInlineSidebar.tsx b/apps/web/src/components/ChatRightPanelInlineSidebar.tsx index b5fa8c636..cd839be43 100644 --- a/apps/web/src/components/ChatRightPanelInlineSidebar.tsx +++ b/apps/web/src/components/ChatRightPanelInlineSidebar.tsx @@ -3,6 +3,8 @@ import { type CSSProperties, type ReactNode, useCallback } from "react"; import { isElectron } from "../env"; import { cn } from "../lib/utils"; import { + RIGHT_PANEL_AGENTS_INLINE_DEFAULT_WIDTH, + RIGHT_PANEL_AGENTS_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, RIGHT_PANEL_INLINE_DEFAULT_WIDTH, RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, RIGHT_PANEL_INLINE_SIDEBAR_MAX_WIDTH, @@ -16,22 +18,37 @@ export { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY }; const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; +const INLINE_WIDTH_BY_SIZE = { + default: { + defaultWidth: RIGHT_PANEL_INLINE_DEFAULT_WIDTH, + storageKey: RIGHT_PANEL_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, + }, + agents: { + defaultWidth: RIGHT_PANEL_AGENTS_INLINE_DEFAULT_WIDTH, + storageKey: RIGHT_PANEL_AGENTS_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, + }, +} as const; + export function ChatRightPanelInlineSidebar(props: { open: boolean; + size?: keyof typeof INLINE_WIDTH_BY_SIZE; onClose: () => void; - onOpenSourceControl: () => void; + /** Reopens the panel the slot was last showing (the rail handle asks for + * this, and it must reach the route so the URL agrees). */ + onRequestOpen: () => void; children: ReactNode; }) { - const { open, onClose, onOpenSourceControl } = props; + const { open, onClose, onRequestOpen } = props; + const { defaultWidth, storageKey } = INLINE_WIDTH_BY_SIZE[props.size ?? "default"]; const onOpenChange = useCallback( (nextOpen: boolean) => { if (nextOpen) { - onOpenSourceControl(); + onRequestOpen(); return; } onClose(); }, - [onClose, onOpenSourceControl], + [onClose, onRequestOpen], ); const shouldAcceptInlineSidebarWidth = useCallback( ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { @@ -85,7 +102,7 @@ export function ChatRightPanelInlineSidebar(props: { open={open} onOpenChange={onOpenChange} className="w-auto min-h-0 flex-none bg-transparent" - style={{ "--sidebar-width": RIGHT_PANEL_INLINE_DEFAULT_WIDTH } as CSSProperties} + style={{ "--sidebar-width": defaultWidth } as CSSProperties} > {props.children} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 23198a623..9a949acfe 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -74,7 +74,7 @@ import { getRouter } from "../router"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { RIGHT_PANEL_INLINE_SIDEBAR_MIN_WIDTH, - resetSourceControlPanelStateMemoryForTests, + resetRightPanelStateMemoryForTests, } from "../rightPanelLayout"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -2169,7 +2169,7 @@ describe("ChatView timeline estimator parity (full app)", () => { __resetEnvironmentApiOverridesForTests(); resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); - resetSourceControlPanelStateMemoryForTests(); + resetRightPanelStateMemoryForTests(); Reflect.deleteProperty(window, "desktopBridge"); useComposerDraftStore.setState({ draftsByThreadKey: {}, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 178cba486..89aa89fc3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,6 +47,7 @@ import { ELECTRON_HEADER_HEIGHT_CLASS } from "../desktopChrome"; import { isElectron } from "../env"; import { ensureLocalApi, readLocalApi } from "../localApi"; import { + closeAgentsPanelSearchParams, closeRightPanelSearchParams, parseDiffRouteSearch, preserveRightPanelSearchParamsForDraftNavigation, @@ -79,6 +80,7 @@ import { formatElapsed, type McpAuthReconnectAction, type ProviderAuthReconnectAction, + type SubagentProgressItem, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { @@ -120,10 +122,12 @@ import { useWsConnectionStatus } from "../rpc/wsConnectionState"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, - draftSourceControlPanelStateKey, + draftRightPanelStateKey, + useAgentsPanelOpen, useChatHeaderBottomVarRef, useSourceControlPanelOpen, } from "../rightPanelLayout"; +import { publishAgentsPanelSource } from "../agentsPanelStore"; import { buildTemporaryWorktreeBranchName } from "@threadlines/shared/git"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -192,7 +196,11 @@ import { type ComposerGoalSetInput } from "./chat/ComposerGoalBar"; import { getComposerProviderState } from "./chat/composerProviderState"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline, type TimelineProposedPlanState } from "./chat/MessagesTimeline"; +import { + MessagesTimeline, + type TimelineProposedPlanState, + type TimelineTurnAgentsState, +} from "./chat/MessagesTimeline"; import { DraftEmptyState } from "./chat/DraftEmptyState"; import { useFirstRunSetupCard } from "./chat/FirstRunSetupCard"; import { ProviderModelPicker } from "./chat/ProviderModelPicker"; @@ -204,7 +212,7 @@ import { pickedElementFromPreview, type PickedElementContextDraft, } from "../lib/pickedElementContext"; -import type { ThreadBackgroundRunItem } from "./chat/ThreadActivityPopover"; +import type { ThreadBackgroundRunItem } from "./chat/threadActivity"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { FilePreviewDialog, type FilePreviewRequest } from "./chat/FilePreviewDialog"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -327,6 +335,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_SUBAGENT_ITEMS: ReadonlyArray = []; const CODEX_PROVIDER_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_PROVIDER_DRIVER = ProviderDriverKind.make("claudeAgent"); const LAYOUT_STICK_TO_BOTTOM_FRAME_COUNT = 4; @@ -1288,10 +1297,11 @@ export default function ChatView(props: ChatViewProps) { composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const sourceControlOpen = useSourceControlPanelOpen( - rawSearch, - routeKind === "draft" && draftId ? draftSourceControlPanelStateKey(draftId) : routeThreadKey, - ); + const rightPanelStateKey = + routeKind === "draft" && draftId ? draftRightPanelStateKey(draftId) : routeThreadKey; + const agentsPanelOpen = useAgentsPanelOpen(rawSearch, rightPanelStateKey); + const sourceControlOpen = + useSourceControlPanelOpen(rawSearch, rightPanelStateKey) && !agentsPanelOpen; // The diff panel is a drill-in of source control, so the header toggle // treats the right panel as one unit: it stays pressed while a diff is // open and pressing it closes the whole panel. @@ -2680,6 +2690,41 @@ export default function ChatView(props: ChatViewProps) { }); }, [activeThread, draftId, environmentId, navigate, routeKind, threadId]); + /** + * The header's activity chip owns this. Opening takes the right-panel slot + * from source control; closing only clears the agents key, so the slot goes + * back to whatever source control state the thread already had. + */ + const onToggleAgentsPanel = useCallback(() => { + if (!activeThread) { + return; + } + const nextSearch = agentsPanelOpen + ? (previous: Record) => closeAgentsPanelSearchParams(previous) + : (previous: Record) => ({ + ...stripRightPanelSearchParams(previous), + agents: "1" as const, + }); + if (routeKind === "draft" && draftId) { + void navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(draftId), + replace: true, + search: nextSearch, + }); + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, + replace: true, + search: nextSearch, + }); + }, [activeThread, agentsPanelOpen, draftId, environmentId, navigate, routeKind, threadId]); + const onToggleSourceControl = useCallback(() => { if (!activeThread) { return; @@ -3249,6 +3294,36 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadId, activeThreadRef, requestCloseTerminal, setThreadError], ); + + // The agents panel mounts in the route's right-panel slot, beside the chat + // column, so the live turn state it renders has to be published out of here. + useEffect(() => { + if (!activeThreadId) { + publishAgentsPanelSource(null); + return; + } + publishAgentsPanelSource({ + environmentId, + threadId: activeThreadId, + subagents: subagentProgress?.items ?? EMPTY_SUBAGENT_ITEMS, + backgroundRuns, + providerLabel: activeProviderDriver, + threadCwd: gitCwd, + onToggleBackgroundRunTerminal: toggleBackgroundRunTerminal, + onStopBackgroundRun: stopBackgroundRun, + }); + }, [ + activeProviderDriver, + activeThreadId, + backgroundRuns, + environmentId, + gitCwd, + stopBackgroundRun, + subagentProgress?.items, + toggleBackgroundRunTerminal, + ]); + useEffect(() => () => publishAgentsPanelSource(null), []); + const confirmPendingTerminalKill = useCallback(() => { if (!pendingTerminalKill) return; performCloseTerminal( @@ -6041,17 +6116,6 @@ export default function ChatView(props: ChatViewProps) { setPlanScrollTarget(null); }, [activeThread?.id]); - const onViewProposedPlan = useCallback(() => { - if (!sidebarProposedPlan) { - return; - } - const planId = sidebarProposedPlan.id; - setPlanScrollTarget((current) => ({ - planId, - requestKey: (current?.requestKey ?? 0) + 1, - })); - }, [sidebarProposedPlan]); - const onImplementProposedPlanInThread = useCallback(() => { if (!activeProposedPlan) { return; @@ -6275,6 +6339,22 @@ export default function ChatView(props: ChatViewProps) { [activeThread, navigate], ); + /** The turn activity row's agent summary, and the panel it opens. */ + const timelineTurnAgents = useMemo(() => { + const subagents = subagentProgress?.items; + if (!subagents || subagents.length === 0) { + return null; + } + return { + subagents, + onOpenPanel: () => { + if (!agentsPanelOpen) { + onToggleAgentsPanel(); + } + }, + }; + }, [agentsPanelOpen, onToggleAgentsPanel, subagentProgress?.items]); + const timelineProposedPlanState = useMemo( () => ({ activePlanId: hasActionableProposedPlan(activeProposedPlan) @@ -6357,23 +6437,12 @@ export default function ChatView(props: ChatViewProps) { subagentProgress={subagentProgress} forkContext={forkHeaderContext} backgroundRuns={backgroundRuns} + agentsPanelOpen={agentsPanelOpen} onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} - onToggleBackgroundRunTerminal={toggleBackgroundRunTerminal} - onStopBackgroundRun={stopBackgroundRun} - onViewProposedPlan={ - taskProgressProposedPlan !== null && !isGeneralChatThread - ? onViewProposedPlan - : undefined - } - onImplementProposedPlan={ - canImplementProposedPlan ? onImplementProposedPlanInThread : undefined - } - onDismissProposedPlan={ - canImplementProposedPlan ? () => void onDismissProposedPlan() : undefined - } + onToggleAgentsPanel={onToggleAgentsPanel} onOpenForkSourceThread={onOpenForkSourceThread} onToggleTerminal={toggleTerminalVisibility} onToggleSourceControl={onToggleSourceControl} @@ -6469,6 +6538,7 @@ export default function ChatView(props: ChatViewProps) { searchTarget={timelineSearchTarget} planScrollTarget={planScrollTarget} proposedPlanState={timelineProposedPlanState} + turnAgents={timelineTurnAgents} /> {/* scroll to bottom button — shown when user has scrolled away from the bottom. diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 7d9b5260a..16af1d5c5 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -3,6 +3,8 @@ import { type CSSProperties, type ReactNode } from "react"; import { isElectron } from "../env"; import { cn } from "../lib/utils"; import { + RIGHT_PANEL_AGENTS_SHEET_CLASS_NAME, + RIGHT_PANEL_AGENTS_WIDTH, RIGHT_PANEL_INLINE_SIDEBAR_MIN_WIDTH, RIGHT_PANEL_SHEET_BACKDROP_CLASS_NAME, RIGHT_PANEL_SHEET_CLASS_NAME, @@ -11,14 +13,19 @@ import { } from "../rightPanelLayout"; import { Sheet, SheetPopup } from "./ui/sheet"; +const SHEET_CLASS_NAME_BY_SIZE = { + default: RIGHT_PANEL_SHEET_CLASS_NAME, + sourceControl: RIGHT_PANEL_SOURCE_CONTROL_SHEET_CLASS_NAME, + agents: RIGHT_PANEL_AGENTS_SHEET_CLASS_NAME, +} as const; + export function RightPanelSheet(props: { children: ReactNode; open: boolean; onClose: () => void; - size?: "default" | "sourceControl"; + size?: keyof typeof SHEET_CLASS_NAME_BY_SIZE; }) { const size = props.size ?? "default"; - const sourceControl = size === "sourceControl"; return ( diff --git a/apps/web/src/components/chat/AgentsPanel.browser.tsx b/apps/web/src/components/chat/AgentsPanel.browser.tsx new file mode 100644 index 000000000..87cf12f21 --- /dev/null +++ b/apps/web/src/components/chat/AgentsPanel.browser.tsx @@ -0,0 +1,245 @@ +import "../../index.css"; + +import { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import { page } from "vite-plus/test/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import type { SubagentProgressItem, SubagentProgressState } from "../../session-logic"; +import { AgentsPanel } from "./AgentsPanel"; +import { ThreadActivityChip } from "./ThreadActivityPopover"; +import type { ThreadBackgroundRunItem } from "./threadActivity"; + +const transcriptRpcMock = vi.hoisted(() => vi.fn()); + +vi.mock("./subagentTranscriptClient", () => ({ + readSubagentTranscriptPage: transcriptRpcMock, +})); + +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const THREAD_ID = ThreadId.make("thread-agents"); + +function buildSubagent(overrides: Partial = {}): SubagentProgressItem { + return { + id: "agent-1", + agentThreadId: "agent-1", + transcriptAgentId: "agent-1", + turnId: null, + label: "Explore subagent", + nickname: null, + role: null, + objective: "Sweep the router for panel wiring", + status: "running", + statusLabel: "Running", + model: null, + reasoningEffort: null, + liveBody: null, + telemetry: null, + createdAt: "2026-08-11T10:00:00.000Z", + updatedAt: "2026-08-11T10:00:30.000Z", + ...overrides, + }; +} + +const TERMINAL_RUN: ThreadBackgroundRunItem = { + id: "terminal:default", + source: "terminal", + terminalId: "default", + terminalVisible: false, + label: "Terminal 1", + command: "vp run dev", + detail: "Terminal 1 - C:\\repo", + cwd: "C:\\repo", + statusLabel: "Running", + urls: [], + pid: null, + port: null, + elapsed: "2m", + canStop: true, +}; + +function renderPanel( + props: Partial[0]> = {}, + onToggleBackgroundRunTerminal = vi.fn(), +) { + return render( +
+ +
, + ); +} + +describe("AgentsPanel", () => { + beforeEach(() => { + transcriptRpcMock.mockReset(); + transcriptRpcMock.mockResolvedValue({ + entries: [{ role: "assistant", text: "Walked the route files.", toolUses: [] }], + truncated: false, + offset: 0, + totalEntries: 1, + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("opens from the header activity chip", async () => { + const onToggleAgentsPanel = vi.fn(); + const subagentProgress: SubagentProgressState = { + items: [], + activeCount: 2, + completedCount: 0, + failedCount: 0, + totalCount: 2, + summary: "2 subagents running", + badge: { label: "2", ariaLabel: "2 subagents running", tone: "active", pulse: true }, + }; + const mounted = await render( +
+ +
, + ); + + try { + const chip = page.getByRole("button", { name: "2 subagents running" }); + await expect.element(chip).toHaveAttribute("aria-pressed", "false"); + await chip.click(); + expect(onToggleAgentsPanel).toHaveBeenCalledTimes(1); + } finally { + await mounted.unmount(); + } + }); + + it("draws a branch for every state the turn is in", async () => { + const mounted = await renderPanel({ + subagents: [ + buildSubagent({ id: "running", label: "Router sweep", status: "running" }), + buildSubagent({ + id: "waiting", + label: "Migration writer", + status: "waiting", + statusLabel: "Needs approval", + }), + buildSubagent({ + id: "failed", + label: "Type checker", + status: "failed", + statusLabel: "Failed", + }), + buildSubagent({ + id: "done", + label: "Doc reader", + status: "completed", + statusLabel: "Done", + }), + ], + backgroundRuns: [TERMINAL_RUN], + }); + + try { + await expect.element(page.getByText("Router sweep")).toBeVisible(); + + const branches = [...document.querySelectorAll("[data-agent-branch='true']")]; + expect(branches.map((branch) => branch.getAttribute("data-agent-branch-status"))).toEqual([ + "running", + "running", + "waiting", + "failed", + "completed", + ]); + expect(branches.map((branch) => branch.getAttribute("data-agent-branch-kind"))).toContain( + "run", + ); + + // A run is transcript-less, so it says where it came from instead. + const tags = [...document.querySelectorAll("[data-agent-branch-tag='true']")]; + expect(tags.map((tag) => tag.textContent)).toEqual(["terminal"]); + } finally { + await mounted.unmount(); + } + }); + + it("tags a detected run with the provider that reported it", async () => { + const mounted = await renderPanel({ + backgroundRuns: [ + { + ...TERMINAL_RUN, + id: "detected:1", + source: "detected", + terminalId: null, + label: "Dev server", + port: 5173, + }, + ], + }); + + try { + await expect.element(page.getByText("codex · detected")).toBeVisible(); + } finally { + await mounted.unmount(); + } + }); + + it("toggles the terminal when a run branch is pressed instead of drilling in", async () => { + const onToggleBackgroundRunTerminal = vi.fn(); + const mounted = await renderPanel( + { backgroundRuns: [TERMINAL_RUN] }, + onToggleBackgroundRunTerminal, + ); + + try { + await page.getByRole("button", { name: "Open Terminal 1 terminal" }).click(); + expect(onToggleBackgroundRunTerminal).toHaveBeenCalledWith("default"); + // Still the tree: a run never replaces the panel with a transcript. + expect(document.querySelector("[data-agents-panel='tree']")).not.toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("drills into a subagent's transcript and comes back to the tree", async () => { + const mounted = await renderPanel({ + subagents: [buildSubagent({ label: "Router sweep" })], + }); + + try { + await page.getByRole("button", { name: "Open Router sweep transcript" }).click(); + + await expect.element(page.getByText("Walked the route files.")).toBeVisible(); + expect(document.querySelector("[data-agents-panel='drill-in']")).not.toBeNull(); + + await page.getByRole("button", { name: "Back to agents" }).click(); + + await expect.element(page.getByText("Sweep the router for panel wiring")).toBeVisible(); + expect(document.querySelector("[data-agents-panel='tree']")).not.toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("says so when the turn has nothing running", async () => { + const mounted = await renderPanel(); + + try { + await expect.element(page.getByText("No agents on this turn.")).toBeVisible(); + } finally { + await mounted.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/AgentsPanel.tsx b/apps/web/src/components/chat/AgentsPanel.tsx new file mode 100644 index 000000000..66f138597 --- /dev/null +++ b/apps/web/src/components/chat/AgentsPanel.tsx @@ -0,0 +1,320 @@ +import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import { XIcon } from "lucide-react"; +import { memo, useCallback, useMemo, useState, type CSSProperties } from "react"; + +import type { SubagentProgressItem } from "../../session-logic"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { LiveNode } from "../ui/threadline"; +import { SubagentInspector } from "./SubagentInspector"; +import { deriveSubagentDisplayDetails, type ThreadBackgroundRunItem } from "./threadActivity"; +import { + buildAgentBranches, + formatAgentsHeaderMeta, + type AgentBranch, + type AgentBranchStatus, +} from "./agentsPanel.logic"; + +export interface AgentsPanelProps { + environmentId: EnvironmentId; + threadId: ThreadId; + subagents: ReadonlyArray; + backgroundRuns: ReadonlyArray; + /** Drives the trunk hue and the provenance chip, e.g. `codex`. */ + providerLabel?: string | null | undefined; + /** Working directory, used to resolve file references in agent prose. */ + threadCwd?: string | null | undefined; + onToggleBackgroundRunTerminal: (terminalId: string) => void; + onStopBackgroundRun: (run: ThreadBackgroundRunItem) => void; + onClose?: (() => void) | undefined; +} + +/** The trunk takes the provider's own hue so the panel reads as that + * provider's work; anything else falls back to the hairline colour. */ +function trunkColor(providerLabel: string | null | undefined): string { + const provider = providerLabel?.trim().toLowerCase(); + if (provider?.includes("claude")) { + return "var(--provider-claude)"; + } + if (provider?.includes("codex")) { + return "var(--provider-codex)"; + } + return "var(--border)"; +} + +const TRUNK_STYLE: CSSProperties = { + background: + "linear-gradient(to bottom, color-mix(in oklab, var(--agents-trunk) 38%, transparent), color-mix(in oklab, var(--agents-trunk) 10%, transparent) 72%, transparent)", +}; + +const ARM_STYLE: CSSProperties = { + background: "color-mix(in oklab, var(--agents-trunk) 26%, transparent)", +}; + +function BranchNode({ status }: { status: AgentBranchStatus }) { + if (status === "running") { + return ; + } + return ( +