diff --git a/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts b/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts index e48595261..799fc0782 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts @@ -189,6 +189,170 @@ describe("CodexAdapter item mapping", () => { }); }); + it("times a forked child's replayed first turn from the thread's own start", () => { + // A forked child's history arrives as a synthetic turn -- Codex ids it + // "rollout-N" -- with no startedAt, completedAt or durationMs, while every + // real turn after it is stamped. Codex times a turn rather than an item, so + // without a fallback the agent's opening words are the only untimed entries + // in the transcript, which reads as the timestamps sitting a row too low. + const thread = { + id: "forked-child", + createdAt: 1_786_487_612, + turns: [ + { + id: "rollout-2", + status: "completed", + startedAt: null, + completedAt: null, + items: [{ id: "assistant-1", type: "agentMessage", text: "I'll delegate both counts." }], + }, + { + id: "019ff2f5-a952-7c10-9fda-66f5498a6d49", + status: "completed", + startedAt: 1_786_487_613, + items: [{ id: "assistant-2", type: "agentMessage", text: "50 .tsx files." }], + }, + ], + } as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"]; + + assert.deepStrictEqual( + mapCodexSubagentTranscript(thread).entries.map((entry) => entry.at), + ["2026-08-11T22:33:32.000Z", "2026-08-11T22:33:33.000Z"], + ); + }); + + it("drops the parent history a forked child replays before its own first turn", () => { + // Codex spawns a subagent by forking, so the child's stored history opens + // with the parent's conversation replayed as an untimed turn. Rendering it + // labelled the operator's own message to the main thread as the instruction + // this agent was given, and attributed the main thread's reply to the child. + const thread = { + id: "forked-child", + forkedFromId: "parent-thread", + parentThreadId: "parent-thread", + createdAt: 1_786_487_612, + turns: [ + { + id: "rollout-2", + status: "completed", + startedAt: null, + completedAt: null, + items: [ + { id: "user-1", type: "userMessage", text: "can you start up subagents again" }, + { id: "assistant-1", type: "agentMessage", text: "Sure, starting two agents." }, + ], + }, + { + id: "019ff2f5-a952-7c10-9fda-66f5498a6d49", + status: "completed", + startedAt: 1_786_487_613, + items: [{ id: "assistant-2", type: "agentMessage", text: "50 .tsx files." }], + }, + ], + } as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"]; + + assert.deepStrictEqual( + mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text), + ["50 .tsx files."], + ); + }); + + it("drops the parent turn that was live at the fork, which carries a real time", () => { + // The second shape of the same inheritance, and the one a "no timestamps" + // rule slid straight past: the turn the parent was in the middle of when it + // spawned this child comes across with an ordinary id and a startedAt from + // before the child existed. Measured from a real fork: the parent's live + // turn started 11s before creation, the child's own work 1s after it. + const createdAt = 1_786_558_783; + const thread = { + id: "forked-child-live-parent-turn", + forkedFromId: "parent-thread", + parentThreadId: "parent-thread", + createdAt, + turns: [ + { + id: "rollout-2", + status: "completed", + startedAt: null, + items: [{ id: "assistant-1", type: "agentMessage", text: "Older replayed history." }], + }, + { + id: "019ff733-7569-7ad2-9171-d6f19574734f", + status: "interrupted", + startedAt: createdAt - 11, + items: [ + { id: "user-1", type: "userMessage", text: "can you run just 1 subagent this time" }, + { + id: "assistant-2", + type: "agentMessage", + text: "I can run exactly one subagent now.", + }, + ], + }, + { + id: "019ff733-a3ec-7060-912e-f858d78b9ef9", + status: "completed", + startedAt: createdAt + 1, + items: [{ id: "assistant-3", type: "agentMessage", text: "Read-only scan complete." }], + }, + ], + } as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"]; + + assert.deepStrictEqual( + mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text), + ["Read-only scan complete."], + ); + }); + + it("keeps a child's own first turn that starts in the second it was created", () => { + const createdAt = 1_786_558_783; + const thread = { + id: "forked-child-instant-start", + forkedFromId: "parent-thread", + createdAt, + turns: [ + { + id: "019ff733-7569-7ad2-9171-d6f19574734f", + status: "completed", + startedAt: createdAt - 4, + items: [{ id: "assistant-1", type: "agentMessage", text: "Parent's own words." }], + }, + { + id: "019ff733-a3ec-7060-912e-f858d78b9ef9", + status: "completed", + startedAt: createdAt, + items: [{ id: "assistant-2", type: "agentMessage", text: "Off to work." }], + }, + ], + } as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"]; + + assert.deepStrictEqual( + mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text), + ["Off to work."], + ); + }); + + it("keeps every turn when a forked child has no timed turn to start from", () => { + const thread = { + id: "forked-child-untimed", + forkedFromId: "parent-thread", + createdAt: 1_786_487_612, + turns: [ + { + id: "rollout-2", + status: "completed", + startedAt: null, + items: [{ id: "assistant-1", type: "agentMessage", text: "Only content there is." }], + }, + ], + } as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"]; + + assert.deepStrictEqual( + mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text), + ["Only content there is."], + ); + }); + it("uses source ancestry metadata and honors transcript limits", () => { const thread = { id: "grandchild-thread", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6748ee1b2..5e5836738 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -406,19 +406,55 @@ export function mapCodexSubagentTranscript( readonly fromEnd?: boolean; }, ): ProviderSubagentTranscriptResult { - const entries = thread.turns.flatMap((turn) => + // Codex stamps a time per turn rather than per item, and a forked child's + // first turn is a synthetic replay of the history it inherited (`turn.id` is + // literally "rollout-2"), which carries no times at all. Its entries are the + // agent's opening words, so leaving them blank reads as the transcript's + // timestamps being shifted a row. The thread's own creation time is the + // honest stand-in: it is a required field, and it lands a second or two + // before the replayed turn's real records. + const threadStartedAt = Number.isFinite(thread.createdAt) + ? isoFromEpochSeconds(thread.createdAt) + : undefined; + // Codex spawns a subagent by forking its parent, so the child inherits the + // parent's conversation and replays it at the head of its own history. None + // of it is the child's work: the replayed user message is what the operator + // typed to the main thread, which the panel then labelled as the instruction + // this agent was given, and the replayed assistant message is the main + // thread's own reply attributed to the child. + // + // The inheritance arrives in two shapes, and only one of them is timeless: an + // older block Codex ids `rollout-N` with no times at all, and the parent turn + // that was live at the moment of the fork, which carries an ordinary id and a + // real `startedAt` -- from *before* this thread existed. What separates them + // from the child's own work is therefore not the ids or the missing times but + // the clock: a turn that started before the child was created cannot be the + // child's. Measured against two real forks, own work begins one and two + // seconds after `createdAt`, so the comparison is strict rather than fuzzy. + const forkedThreadCreatedAt = Number.isFinite(thread.createdAt) ? thread.createdAt : null; + const firstOwnTurnIndex = + thread.forkedFromId && forkedThreadCreatedAt !== null + ? thread.turns.findIndex( + (turn) => + turn.startedAt !== undefined && + turn.startedAt !== null && + turn.startedAt >= forkedThreadCreatedAt, + ) + : 0; + // No turn of the child's own leaves nothing to tell the inheritance from, so + // the thread keeps every turn rather than losing its only content. + const ownTurns = firstOwnTurnIndex > 0 ? thread.turns.slice(firstOwnTurnIndex) : thread.turns; + const entries = ownTurns.flatMap((turn) => turn.items.flatMap((item) => { const entry = mapCodexStoredItem(item); - return entry === undefined - ? [] - : [ - { - ...entry, - ...(turn.startedAt !== undefined && turn.startedAt !== null - ? { at: isoFromEpochSeconds(turn.startedAt) } - : {}), - }, - ]; + if (entry === undefined) { + return []; + } + const at = + turn.startedAt !== undefined && turn.startedAt !== null + ? isoFromEpochSeconds(turn.startedAt) + : threadStartedAt; + return [{ ...entry, ...(at === undefined ? {} : { at }) }]; }), ); const limit = diff --git a/apps/web/src/agentsPanelStore.ts b/apps/web/src/agentsPanelStore.ts new file mode 100644 index 000000000..07c1662d2 --- /dev/null +++ b/apps/web/src/agentsPanelStore.ts @@ -0,0 +1,84 @@ +/** + * 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, ThreadSubagentHistoryEntry } from "./session-logic"; +import type { ThreadBackgroundRunItem } from "./components/chat/threadActivity"; + +export interface AgentsPanelSource { + environmentId: EnvironmentId; + threadId: ThreadId; + subagents: ReadonlyArray; + backgroundRuns: ReadonlyArray; + /** Every agent the thread has run, live or long finished. Published alongside + * the live items so the panel and the conversation's receipts resolve the + * same set of agents. */ + history: ReadonlyArray; + /** Provider driver label, e.g. `codex`; drives the trunk hue and run chips. */ + providerLabel: string | null; + /** True from the moment a turn is dispatched until it settles. Lets the panel + * say it is waiting rather than claim the thread has never run an agent + * while the provider handoff is still in flight. */ + turnInFlight: boolean; + threadCwd: string | null; + onToggleBackgroundRunTerminal: (terminalId: string) => void; + onStopBackgroundRun: (run: ThreadBackgroundRunItem) => void; +} + +interface AgentsPanelStoreState { + source: AgentsPanelSource | null; + /** + * The agent whose transcript the panel is drilled into. It lives here rather + * than inside the panel because the drill-in is reachable from outside it: + * a finished agent's receipt in the conversation opens the rail already + * pointed at that agent. + */ + selectedAgentId: string | null; + publishSource: (source: AgentsPanelSource | null) => void; + selectAgent: (agentId: string | null) => void; +} + +export const useAgentsPanelStore = create((set) => ({ + source: null, + selectedAgentId: null, + publishSource: (source) => { + set((state) => ({ + source, + // A drill-in belongs to the thread it was opened from; leaving the + // thread drops it rather than pointing the panel at a stale agent. + selectedAgentId: state.source?.threadId === source?.threadId ? state.selectedAgentId : null, + })); + }, + selectAgent: (agentId) => { + set({ selectedAgentId: agentId }); + }, +})); + +export function useAgentsPanelSource(): AgentsPanelSource | null { + return useAgentsPanelStore((state) => state.source); +} + +export function useSelectedAgentId(): string | null { + return useAgentsPanelStore((state) => state.selectedAgentId); +} + +export function publishAgentsPanelSource(source: AgentsPanelSource | null): void { + useAgentsPanelStore.getState().publishSource(source); +} + +export function selectAgentsPanelAgent(agentId: string | null): void { + useAgentsPanelStore.getState().selectAgent(agentId); +} + +export function resetAgentsPanelSourceForTests(): void { + useAgentsPanelStore.setState({ source: null, selectedAgentId: null }); +} diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index f2851e7b0..4d73a4ad2 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -413,6 +413,91 @@ describe("ChatMarkdown", () => { } }); + /** Prose that cites one file as a backticked path, another as a markdown link, + * and a tool name that is not a file at all. */ + const MIXED_REFERENCE_PROSE = + "The gutter is set in `apps/web/src/components/chat/AgentsPanel.tsx:300`, and " + + "[AgentsPanel.tsx](file:///repo/project/docs/AgentsPanel.tsx) documents it. " + + "Call `spawn_agent` to start one."; + + it("renders inline-code file references as the chip a file link gets", async () => { + const cwd = "/repo/project"; + setActiveFileViewerContext({ + environmentId: CHAT_MARKDOWN_ENVIRONMENT_ID, + cwd, + threadRef: CHAT_MARKDOWN_THREAD_REF, + }); + const screen = await render(); + + try { + // Both citations went through one pre-pass, so the two namesakes carry the + // parent suffix that tells them apart -- the inline one included. + const chip = page.getByRole("link", { name: "AgentsPanel.tsx · components/chat · L300" }); + await expect.element(chip).toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "AgentsPanel.tsx · project/docs" })) + .toBeInTheDocument(); + + // The tool name is the only thing left as inline code. + expect( + [...document.querySelectorAll(".chat-markdown code")].map((el) => el.textContent), + ).toEqual(["spawn_agent"]); + + await chip.click(); + await vi.waitFor(() => { + const state = useFileViewerStore.getState(); + expect(state.isOpen).toBe(true); + expect(state.activePath).toBe("apps/web/src/components/chat/AgentsPanel.tsx"); + expect(state.revealLine).toBe(300); + }); + } finally { + await screen.unmount(); + } + }); + + it("keeps references as written while a search hit is highlighted", async () => { + // A chip drops the highlight and shortens the path, so the characters the + // reader searched for could leave the page entirely. + const screen = await render( + , + ); + + try { + await expect + .element( + page.getByRole("button", { name: "apps/web/src/components/chat/AgentsPanel.tsx:300" }), + ) + .toBeInTheDocument(); + // The searched characters are still on the page, which a shortened label + // could not promise. + expect(document.body.textContent).toContain("components/chat"); + } finally { + await screen.unmount(); + } + }); + + it("leaves inline-code file references alone when there is no workspace to resolve against", async () => { + const screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("button", { name: "apps/web/src/AgentsPanel.tsx:300" })) + .toBeInTheDocument(); + expect(document.querySelectorAll("a.chat-markdown-file-link")).toHaveLength(0); + } finally { + await screen.unmount(); + } + }); + it("keeps normal web links unchanged", async () => { const screen = await render( , diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index eddf97840..5a6e38849 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -44,6 +44,7 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { localhostUrlFromText, + type MarkdownFileLinkMeta, normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -270,6 +271,14 @@ interface MarkdownFileLinkProps { label: string; theme: "light" | "dark"; className?: string | undefined; + /** + * Set when the chip was built from an inline-code reference rather than a + * link. Such a reference is often a bare file name (`AgentsPanel.tsx:300`) + * whose real location is only known after a workspace search, so it opens + * through {@link openChatFileReference} and the affordances that need a + * settled path (copy path, reveal, open in editor) stay off. + */ + chatReference?: string | undefined; } const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(\s*(<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\s*\)/g; @@ -355,6 +364,85 @@ function normalizeMarkdownLinkHrefKey(href: string): string { return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; } +/** + * The one label a clickable file wears: name, then only as much of its parent + * path as it takes to tell it from its namesakes in the same document, then the + * position. Shared by markdown links and inline-code references so an + * `AgentsPanel.tsx:300` written either way reads identically. + */ +function buildFileLinkLabel(meta: MarkdownFileLinkMeta, parentSuffix: string | undefined): string { + const labelParts = [meta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (meta.line) { + labelParts.push(`L${meta.line}${meta.column ? `:C${meta.column}` : ""}`); + } + return labelParts.join(" · "); +} + +const BLOCK_FENCE_MARKER_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/; +const INLINE_CODE_RUN_PATTERN = /(`+)([^`\n]+)\1/g; +const EMPTY_FILE_LINK_META_MAP: ReadonlyMap = new Map(); + +/** + * Every inline code span in a markdown document, skipping fenced blocks. + * + * Deliberately approximate: it exists so the file-link pre-pass sees the same + * references the `code` renderer will, and anything it misses falls through to + * plain inline code rather than to a wrong label. + */ +function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + let openFence: { char: string; length: number } | null = null; + + for (const line of text.split("\n")) { + const fenceMatch = BLOCK_FENCE_MARKER_REGEX.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1]!; + if (openFence === null) { + openFence = { char: marker[0]!, length: marker.length }; + } else if ( + marker[0] === openFence.char && + marker.length >= openFence.length && + (fenceMatch[2] ?? "").trim() === "" + ) { + openFence = null; + } + continue; + } + if (openFence !== null) continue; + + for (const match of line.matchAll(INLINE_CODE_RUN_PATTERN)) { + const span = match[2]?.trim(); + if (span) spans.push(span); + } + } + + return spans; +} + +/** + * Resolved metadata for every inline-code file reference in a document, keyed by + * the span as written (which is also what {@link openChatFileReference} takes). + */ +function buildInlineCodeFileLinkMeta( + text: string, + cwd: string | undefined, +): ReadonlyMap { + const metaBySpan = new Map(); + for (const span of extractInlineCodeSpans(text)) { + if (metaBySpan.has(span)) continue; + // A dev-server address reads as a file reference too (`127.0.0.1:8080`); it + // is handled as an address by the renderer and must not become a chip. + if (localhostUrlFromText(span)) continue; + if (!parseChatFileReference(span)) continue; + const meta = resolveMarkdownFileLinkMeta(span, cwd); + if (meta) metaBySpan.set(span, meta); + } + return metaBySpan; +} + const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -366,9 +454,28 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ label, theme, className, + chatReference, }: MarkdownFileLinkProps) { const entryLabel = kind === "directory" ? "folder" : "file"; + /** Opens an inline-code reference, resolving bare names through workspace + * search. Returns false when this chip did not come from one. */ + const openReference = useCallback(() => { + if (chatReference === undefined) { + return false; + } + void openChatFileReference(chatReference).then((opened) => { + if (!opened) { + toastManager.add({ + type: "error", + title: "File not found in workspace", + description: chatReference, + }); + } + }); + return true; + }, [chatReference]); + const handleOpenExternally = useCallback(() => { const api = readLocalApi(); if (!api) { @@ -420,6 +527,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); const handleOpen = useCallback(() => { + if (openReference()) { + return; + } if (openInInternalViewer()) { return; } @@ -428,10 +538,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } handleOpenExternally(); - }, [handleOpenExternally, handleRevealInFileManager, kind, openInInternalViewer]); + }, [handleOpenExternally, handleRevealInFileManager, kind, openInInternalViewer, openReference]); const handleOpenInViewer = useCallback(() => { - if (openInInternalViewer()) { + if (openReference() || openInInternalViewer()) { return; } toastManager.add( @@ -441,7 +551,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ description: `${displayPath} is not available in the active project workspace.`, }), ); - }, [displayPath, openInInternalViewer]); + }, [displayPath, openInInternalViewer, openReference]); const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { @@ -519,12 +629,23 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, className)} data-entry-kind={kind} data-workspace-scope={isInWorkspace ? "internal" : "external"} + data-chat-file-reference={chatReference} onClick={(event) => { event.preventDefault(); event.stopPropagation(); handleOpen(); }} - onContextMenu={handleContextMenu} + // Enter already activates a link; Space does not, and the chip is a + // single tab stop that reads as a button, so it fires on both. + onKeyDown={(event) => { + if (event.key === " ") { + event.preventDefault(); + handleOpen(); + } + }} + // An inline reference may be a bare name resolved by search, so the + // menu's path actions would copy or reveal a guess. + onContextMenu={chatReference === undefined ? handleContextMenu : undefined} > + searchHighlightQuery?.trim() + ? EMPTY_FILE_LINK_META_MAP + : buildInlineCodeFileLinkMeta(text, cwd), + [cwd, searchHighlightQuery, text], + ); + // Links and inline references share one pre-pass, so the same file cited both + // ways gets the same parent-suffix disambiguation and the same resolved kind. + const fileLinkMetaByKey = useMemo(() => { + if (inlineCodeFileLinkMetaBySpan.size === 0) { + return markdownFileLinkMetaByHref; + } + const merged = new Map(markdownFileLinkMetaByHref); + for (const [span, meta] of inlineCodeFileLinkMetaBySpan) { + if (!merged.has(span)) merged.set(span, meta); + } + return merged; + }, [inlineCodeFileLinkMetaBySpan, markdownFileLinkMetaByHref]); const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + const filePaths = [...fileLinkMetaByKey.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); - const fileLinkKindByPath = useMarkdownFileLinkKinds( - markdownFileLinkMetaByHref, - environmentId, - cwd, - ); + }, [fileLinkMetaByKey]); + const fileLinkKindByPath = useMarkdownFileLinkKinds(fileLinkMetaByKey, environmentId, cwd); const markdownUrlTransform = useCallback( (href: string) => { const rewrittenFileHref = rewriteMarkdownFileUriHref(href); @@ -712,17 +851,6 @@ function ChatMarkdownDocument({ ); } - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); - } - return ( @@ -765,6 +896,32 @@ function ChatMarkdownDocument({ ); } + // Opted in, this reference wears the same chip a markdown file link + // does: in a narrow column the raw path wraps mid-token and reads as a + // broken button, and one clickable-file style beats two. + const inlineFileReference = className || !text ? null : text.trim(); + const inlineFileLinkMeta = inlineFileReference + ? inlineCodeFileLinkMetaBySpan.get(inlineFileReference) + : undefined; + if (inlineFileReference && inlineFileLinkMeta) { + return ( + + ); + } if (className || !text || !parseChatFileReference(text)) { return ( @@ -840,6 +997,7 @@ function ChatMarkdownDocument({ diffThemeName, fileLinkKindByPath, fileLinkParentSuffixByPath, + inlineCodeFileLinkMetaBySpan, inlineContext, isStreaming, markdownFileLinkMetaByHref, diff --git a/apps/web/src/components/ChatRightPanel.tsx b/apps/web/src/components/ChatRightPanel.tsx new file mode 100644 index 000000000..90dc19deb --- /dev/null +++ b/apps/web/src/components/ChatRightPanel.tsx @@ -0,0 +1,77 @@ +/** + * The right sidebar's chrome: one tab strip over one content area, shared by + * the inline sidebar and the overlay sheet and by both chat routes. + * + * The routes supply the mounted surfaces as children and decide which of them + * is laid out, because they own the queries and worker pools that have to stay + * warm behind an inactive tab. This component owns everything that is true of + * the sidebar regardless of which surface is showing — which is also the fix + * for a bare panel ever appearing: there is one way in, and it brings the strip + * (or the launcher) with it. + */ +import { XIcon } from "lucide-react"; +import type { ReactElement, ReactNode } from "react"; + +import { Button } from "./ui/button"; +import { RightPanelLauncher } from "./chat/RightPanelLauncher"; +import type { RightPanelLauncherStates } from "./chat/rightPanelLauncherState"; +import { RightPanelTabStrip } from "./chat/RightPanelTabStrip"; +import type { RightPanelTab } from "../rightPanelTabs"; + +export function ChatRightPanel(props: { + openTabs: ReadonlyArray; + availableTabs: ReadonlyArray; + activeTab: RightPanelTab | null; + liveTabs?: ReadonlyArray; + /** What the launcher's rows report. The route composes it, because only the + * route holds the thread's git status and agent state. */ + launcherSurfaceStates?: RightPanelLauncherStates | undefined; + onSelectTab: (tab: RightPanelTab) => void; + onCloseTab: (tab: RightPanelTab) => void; + /** Present in overlay mode, where the sidebar covers the conversation. */ + onDismiss?: (() => void) | undefined; + children: ReactNode; +}): ReactElement { + const { activeTab, onDismiss } = props; + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ChatRightPanelInlineSidebar.browser.tsx b/apps/web/src/components/ChatRightPanelInlineSidebar.browser.tsx new file mode 100644 index 000000000..c7d59d5ac --- /dev/null +++ b/apps/web/src/components/ChatRightPanelInlineSidebar.browser.tsx @@ -0,0 +1,61 @@ +import "../index.css"; + +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { ChatRightPanelInlineSidebar } from "./ChatRightPanelInlineSidebar"; +import { RIGHT_PANEL_INSET_CSS_VAR } from "../rightPanelLayout"; + +function readPanelInset(): string { + return document.documentElement.style.getPropertyValue(RIGHT_PANEL_INSET_CSS_VAR); +} + +function renderSidebar(open: boolean) { + return render( +
+
conversation
+ +
panel body
+
+
, + ); +} + +describe("ChatRightPanelInlineSidebar", () => { + afterEach(() => { + document.documentElement.style.removeProperty(RIGHT_PANEL_INSET_CSS_VAR); + document.body.innerHTML = ""; + }); + + // Toasts and other body-portaled overlays are fixed to the viewport, so the + // layout cannot push them off the panel. They read this instead. + it("publishes how much of the right edge the open panel takes", async () => { + const mounted = await renderSidebar(true); + + try { + const slot = document.querySelector("[data-slot='sidebar-wrapper']"); + expect(slot).not.toBeNull(); + const slotWidth = Math.round(slot!.getBoundingClientRect().width); + expect(slotWidth).toBeGreaterThan(0); + await vi.waitFor(() => { + expect(readPanelInset()).toBe(`${slotWidth}px`); + }); + } finally { + await mounted.unmount(); + } + }); + + it("reports no inset while the panel is closed, and clears it on unmount", async () => { + const mounted = await renderSidebar(false); + + try { + await vi.waitFor(() => { + expect(readPanelInset()).toBe("0px"); + }); + } finally { + await mounted.unmount(); + } + + expect(readPanelInset()).toBe("0px"); + }); +}); diff --git a/apps/web/src/components/ChatRightPanelInlineSidebar.tsx b/apps/web/src/components/ChatRightPanelInlineSidebar.tsx index b5fa8c636..fee765885 100644 --- a/apps/web/src/components/ChatRightPanelInlineSidebar.tsx +++ b/apps/web/src/components/ChatRightPanelInlineSidebar.tsx @@ -9,6 +9,7 @@ import { RIGHT_PANEL_INLINE_SIDEBAR_MIN_WIDTH, RIGHT_PANEL_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, normalizeRightPanelStoredWidth, + useRightPanelInsetVarRef, } from "../rightPanelLayout"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; @@ -19,19 +20,24 @@ const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; export function ChatRightPanelInlineSidebar(props: { open: boolean; 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; + // Body-portaled overlays (toasts) are fixed to the viewport, so they need to + // be told how wide this slot is to keep off the panel. + const insetRef = useRightPanelInsetVarRef(); 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,6 +91,7 @@ export function ChatRightPanelInlineSidebar(props: { open={open} onOpenChange={onOpenChange} className="w-auto min-h-0 flex-none bg-transparent" + ref={insetRef} style={{ "--sidebar-width": RIGHT_PANEL_INLINE_DEFAULT_WIDTH } as CSSProperties} > { __resetEnvironmentApiOverridesForTests(); resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); - resetSourceControlPanelStateMemoryForTests(); + resetRightPanelTabsForTests(); Reflect.deleteProperty(window, "desktopBridge"); useComposerDraftStore.setState({ draftsByThreadKey: {}, @@ -2689,35 +2687,229 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const sourceControlToggle = await waitForElement( + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + expect(railToggle.disabled).toBe(false); + expect(railToggle.hasAttribute("data-pressed")).toBe(false); + expect(document.querySelector("[data-chat-right-panel='true']")).toBeNull(); + + // The header button opens the sidebar's own chrome, never a bare panel: + // on a fresh draft nothing is open yet, so that chrome is the launcher. + railToggle.click(); + + const changesTile = await waitForElement( () => document.querySelector( - 'button[aria-label="Toggle source control panel"]', - ) as HTMLButtonElement | null, - "Unable to find source control toggle.", + "[data-right-panel-launcher-row='sourceControl']", + ) as HTMLElement | null, + "The header button did not open the sidebar's launcher.", ); - expect(sourceControlToggle.disabled).toBe(false); - expect(sourceControlToggle.hasAttribute("data-pressed")).toBe(false); - expect(document.querySelector('h2[aria-label="Source Control"]')).toBeNull(); + expect(railToggle.hasAttribute("data-pressed")).toBe(true); + expect(document.querySelector("[data-right-panel-strip='true']")).not.toBeNull(); + // A draft has no turn, so Agents is not one of its surfaces. + expect(document.querySelector("[data-right-panel-launcher-row='agents']")).toBeNull(); - sourceControlToggle.click(); + changesTile.click(); await vi.waitFor( () => { expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "1" }); - expect(sourceControlToggle.hasAttribute("data-pressed")).toBe(true); + expect(document.querySelector("[data-right-panel-tab='sourceControl']")).not.toBeNull(); + expect(document.querySelector("[data-source-control-panel='true']")).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, ); - await expect.element(page.getByRole("heading", { name: "Source Control" })).toBeVisible(); - sourceControlToggle.click(); + railToggle.click(); await vi.waitFor( () => { expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "0" }); - expect(sourceControlToggle.hasAttribute("data-pressed")).toBe(false); - expect(document.querySelector('h2[aria-label="Source Control"]')).toBeNull(); + expect(railToggle.hasAttribute("data-pressed")).toBe(false); + expect(document.querySelector("[data-chat-right-panel='true']")).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + /** Every open tab stays mounted so its queries and scroll state survive a tab + * switch, so "showing" has to mean laid out, not just mounted. */ + function visibleSourceControlPanel(): HTMLElement | null { + const panel = document.querySelector( + "[data-source-control-panel='true']", + ) as HTMLElement | null; + return panel && panel.getClientRects().length > 0 ? panel : null; + } + + /** Opens a surface from the launcher, which is what the sidebar shows before + * a thread has any tab in its strip. */ + async function openRightPanelSurfaceFromLauncher(tab: string): Promise { + const tile = await waitForElement( + () => + document.querySelector(`[data-right-panel-launcher-row='${tab}']`) as HTMLElement | null, + `Unable to find the sidebar launcher's ${tab} tile.`, + ); + tile.click(); + } + + it("builds a tab strip from the launcher and switches between its tabs", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-rail-tabs" as MessageId, + targetText: "rail tabs", + }), + }); + + try { + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + railToggle.click(); + + // Nothing open yet, so the sidebar opens on the launcher and offers all + // three of a server thread's surfaces. + await openRightPanelSurfaceFromLauncher("sourceControl"); + + const changesTab = await waitForElement( + () => + document.querySelector("[data-right-panel-tab='sourceControl']") as HTMLElement | null, + "Unable to find the sidebar's Source tab.", + ); + expect(changesTab.getAttribute("data-active")).toBe("true"); + expect(visibleSourceControlPanel()).not.toBeNull(); + expect(document.querySelector("[data-agents-panel]")).toBeNull(); + + // The `+` menu adds the second tab and focuses it. + (document.querySelector("[data-right-panel-add-tab='true']") as HTMLElement).click(); + const agentsMenuItem = await waitForElement( + () => document.querySelector("[data-right-panel-menu-tab='agents']") as HTMLElement | null, + "The + menu never listed Agents.", + ); + agentsMenuItem.click(); + + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).toMatchObject({ agents: "1" }); + expect(document.querySelector("[data-agents-panel='tree']")).not.toBeNull(); + // Source stays in the strip and stays mounted to keep its queries + // warm; the Agents tab is the one on screen. + expect(document.querySelector("[data-right-panel-tab='sourceControl']")).not.toBeNull(); + expect(visibleSourceControlPanel()).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + ( + document.querySelector("[data-right-panel-tab='sourceControl'] [role='tab']") as HTMLElement + ).click(); + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "1" }); + expect(visibleSourceControlPanel()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + // Closing the tabs one at a time empties the strip and lands back on the + // launcher, with the sidebar still open. + ( + document.querySelector("[data-right-panel-close-tab='sourceControl']") as HTMLElement + ).click(); + await vi.waitFor( + () => { + expect(document.querySelector("[data-right-panel-tab='sourceControl']")).toBeNull(); + expect(document.querySelector("[data-agents-panel='tree']")).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + (document.querySelector("[data-right-panel-close-tab='agents']") as HTMLElement).click(); + await vi.waitFor( + () => { + expect(document.querySelector("[data-right-panel-launcher='true']")).not.toBeNull(); + expect(document.querySelector("[data-right-panel-tab='agents']")).toBeNull(); + // The sidebar itself is still showing, so its toggle stays pressed. + expect(railToggle.hasAttribute("data-pressed")).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + + // The header button is what puts the sidebar away. + railToggle.click(); + await vi.waitFor( + () => { + expect(document.querySelector("[data-chat-right-panel='true']")).toBeNull(); + expect(railToggle.hasAttribute("data-pressed")).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the tab strip inside the overlay sheet on phone widths", async () => { + const mounted = await mountChatView({ + viewport: PHONE_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-rail-sheet" as MessageId, + targetText: "rail sheet", + }), + }); + + try { + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + railToggle.click(); + + // The launcher and the strip live in the sheet at this width, not in an + // inline sidebar. + const launcher = await waitForElement( + () => document.querySelector("[data-right-panel-launcher='true']") as HTMLElement | null, + "The sidebar never opened on a phone width.", + ); + expect(launcher.closest('[data-slot="sheet-popup"]')).not.toBeNull(); + expect(document.querySelector("[data-right-panel-strip='true']")).not.toBeNull(); + + await openRightPanelSurfaceFromLauncher("agents"); + + const agentsPanel = await waitForElement( + () => document.querySelector("[data-agents-panel='tree']") as HTMLElement | null, + "The agents tab never opened on a phone width.", + ); + expect(agentsPanel.closest('[data-slot="sheet-popup"]')).not.toBeNull(); + await expect.element(page.getByText(/No agents yet\./u)).toBeVisible(); + + // Closed and reopened, the sheet comes back on Agents — the same + // closed-to-`agents=1` transition the activity chip performs, which is + // where the sheet used to fail to appear at all. + railToggle.click(); + await vi.waitFor( + () => { + expect(document.querySelector("[data-agents-panel]")).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + railToggle.click(); + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).toMatchObject({ agents: "1" }); + const reopened = document.querySelector("[data-agents-panel='tree']"); + expect(reopened).not.toBeNull(); + expect(reopened?.closest('[data-slot="sheet-popup"]')).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, ); @@ -2746,24 +2938,24 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const sourceControlToggle = await waitForElement( + const railToggle = await waitForElement( () => - document.querySelector( - 'button[aria-label="Toggle source control panel"]', - ) as HTMLButtonElement | null, - "Unable to find source control toggle.", + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", ); - sourceControlToggle.click(); + railToggle.click(); + await openRightPanelSurfaceFromLauncher("sourceControl"); await vi.waitFor( () => { expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "1" }); + expect(document.querySelector("[data-source-control-panel='true']")).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, ); - await expect.element(page.getByRole("heading", { name: "Source Control" })).toBeVisible(); // Sidebar-style navigation carries no search params, which used to reset - // the panel. The other thread still starts closed; memory is per thread. + // the panel. The other thread still starts closed; the strip and its + // active tab are remembered per thread. await mounted.router.navigate({ to: "/$environmentId/$threadId", params: { environmentId: LOCAL_ENVIRONMENT_ID, threadId: secondThreadId }, @@ -2773,7 +2965,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(mounted.router.state.location.pathname).toBe( `/${LOCAL_ENVIRONMENT_ID}/${secondThreadId}`, ); - expect(document.querySelector('h2[aria-label="Source Control"]')).toBeNull(); + expect(document.querySelector("[data-chat-right-panel='true']")).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); @@ -2784,7 +2976,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); await vi.waitFor( () => { - expect(document.querySelector('h2[aria-label="Source Control"]')).not.toBeNull(); + expect(document.querySelector("[data-right-panel-tab='sourceControl']")).not.toBeNull(); + expect(document.querySelector("[data-source-control-panel='true']")).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, ); @@ -2824,17 +3017,13 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const sourceControlToggle = await waitForElement( () => - document.querySelector( - 'button[aria-label="Toggle source control panel"]', - ) as HTMLButtonElement | null, - "Unable to find source control toggle.", + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", ); expect(sourceControlToggle.disabled).toBe(false); expect(sourceControlToggle.hasAttribute("data-pressed")).toBe(false); - await expect - .element(page.getByRole("heading", { name: "Source Control" })) - .not.toBeInTheDocument(); + expect(document.querySelector("[data-chat-right-panel='true']")).toBeNull(); } finally { await mounted.cleanup(); } @@ -2871,46 +3060,45 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const sourceControlToggle = await waitForElement( () => - document.querySelector( - 'button[aria-label="Toggle source control panel"]', - ) as HTMLButtonElement | null, - "Unable to find source control toggle.", + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", ); await vi.waitFor( () => { expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "0" }); expect(sourceControlToggle.hasAttribute("data-pressed")).toBe(false); - expect(document.querySelector('h2[aria-label="Source Control"]')).toBeNull(); + expect(document.querySelector("[data-chat-right-panel='true']")).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); + // The auto-hide left the deep-linked tab in the strip, so reopening the + // sidebar lands straight back on Source rather than on the launcher. sourceControlToggle.click(); - const sourceControlHeading = await waitForElement( - () => - document.querySelector('h2[aria-label="Source Control"]') as HTMLHeadingElement | null, - "Unable to find source control panel heading.", + const sourceControlPanel = await waitForElement( + () => document.querySelector("[data-source-control-panel='true']") as HTMLElement | null, + "Unable to find the source control panel.", ); - const sheetPopup = sourceControlHeading.closest( + const sheetPopup = sourceControlPanel.closest( '[data-slot="sheet-popup"]', ) as HTMLElement | null; expect(sheetPopup).not.toBeNull(); await waitForLayout(); expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "1" }); - // Source control keeps the inline panel's fixed width on phones so a - // dismissible slice of the conversation remains visible. - expect(Math.round(sheetPopup?.getBoundingClientRect().width ?? 0)).toBe( - RIGHT_PANEL_INLINE_SIDEBAR_MIN_WIDTH, - ); + // The rail overlays as a partial sheet on phones, so a dismissible slice + // of the conversation remains visible beside it. + const sheetWidth = Math.round(sheetPopup?.getBoundingClientRect().width ?? 0); + expect(sheetWidth).toBeLessThanOrEqual(RIGHT_PANEL_RAIL_WIDTH); + expect(sheetWidth).toBeLessThan(COMPACT_FOOTER_VIEWPORT.width); } finally { await mounted.cleanup(); } }); - it("opens the whole chat turn diff with the source control return affordance", async () => { + it("opens the whole chat turn diff in the Diff tab with a way back to Source", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithChangedFileSummary(), @@ -2953,16 +3141,130 @@ describe("ChatView timeline estimator parity (full app)", () => { () => { expect(mounted.router.state.location.search).toMatchObject({ diff: "1", - sourceControlReturn: "1", diffTurnId: "turn-chat-diff", }); expect(mounted.router.state.location.search).not.toHaveProperty("diffFilePath"); + // The link opens the sidebar on its Diff tab, tab strip and all. + expect( + document.querySelector("[data-right-panel-tab='diff'][data-active='true']"), + ).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, ); + // The strip is the only navigation the embedded Diff needs: no in-panel + // back-to-Source control, and no header bar restating the active tab -- + // the diff's own toolbar starts straight under the strip. + const diffToolbar = await waitForElement( + () => document.querySelector('[aria-label="Select diff source"]'), + "The diff toolbar should render.", + ); + expect(document.querySelector('button[aria-label="Back to changes"]')).toBeNull(); + const strip = document.querySelector("[data-right-panel-strip='true']")!; + expect( + diffToolbar.getBoundingClientRect().top - strip.getBoundingClientRect().bottom, + ).toBeLessThanOrEqual(10); + } finally { + await mounted.cleanup(); + } + }); + + it("retargets one Diff tab and keeps the Source tab in the strip", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithChangedFileSummary(), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.getTurnDiff) { + return { + diff: [ + "diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx", + "index 1111111..2222222 100644", + "--- a/apps/web/src/components/DiffPanel.tsx", + "+++ b/apps/web/src/components/DiffPanel.tsx", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"), + }; + } + return undefined; + }, + }); + + try { + const railToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle panel"]') as HTMLButtonElement | null, + "Unable to find the rail toggle.", + ); + railToggle.click(); + await openRightPanelSurfaceFromLauncher("sourceControl"); await waitForElement( - () => document.querySelector('button[aria-label="Back to source control"]'), - "Back to source control button should render.", + () => + document.querySelector("[data-right-panel-tab='sourceControl']") as HTMLElement | null, + "Unable to find the sidebar's Source tab.", + ); + + const viewTurnDiffButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "View turn diff", + ) ?? null, + "View turn diff button should render.", + ); + viewTurnDiffButton.click(); + + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).toMatchObject({ + diff: "1", + diffTurnId: "turn-chat-diff", + }); + expect(document.querySelectorAll("[data-right-panel-tab='diff']").length).toBe(1); + // Source stays in the strip, so tabbing back keeps its list. + expect(document.querySelector("[data-right-panel-tab='sourceControl']")).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + + // Back to Source from its tab, then the link again: still one Diff tab. + ( + document.querySelector("[data-right-panel-tab='sourceControl'] [role='tab']") as HTMLElement + ).click(); + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).toMatchObject({ sourceControl: "1" }); + expect(visibleSourceControlPanel()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + viewTurnDiffButton.click(); + await vi.waitFor( + () => { + expect(document.querySelectorAll("[data-right-panel-tab='diff']").length).toBe(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + // Tabbing between Source and Diff keeps the target the Diff tab had. + ( + document.querySelector("[data-right-panel-tab='sourceControl'] [role='tab']") as HTMLElement + ).click(); + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).not.toHaveProperty("diffTurnId"); + }, + { timeout: 8_000, interval: 16 }, + ); + (document.querySelector("[data-right-panel-tab='diff'] [role='tab']") as HTMLElement).click(); + await vi.waitFor( + () => { + expect(mounted.router.state.location.search).toMatchObject({ + diff: "1", + diffTurnId: "turn-chat-diff", + }); + expect(document.querySelectorAll("[data-right-panel-tab='diff']").length).toBe(1); + }, + { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 178cba486..2ac1cb720 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,11 +47,20 @@ import { ELECTRON_HEADER_HEIGHT_CLASS } from "../desktopChrome"; import { isElectron } from "../env"; import { ensureLocalApi, readLocalApi } from "../localApi"; import { - closeRightPanelSearchParams, parseDiffRouteSearch, preserveRightPanelSearchParamsForDraftNavigation, - stripRightPanelSearchParams, } from "../diffRouteSearch"; +import { + availableRightPanelTabs, + focusRightPanelTab, + hideRightPanel, + retargetRightPanelDiff, + rightPanelTabSearchParams, + showRightPanel, + useRightPanelTabs, + type RightPanelDiffTarget, + type RightPanelTab, +} from "../rightPanelTabs"; import { collapseExpandedComposerCursor, parseComposerGoalCommand, @@ -70,6 +79,7 @@ import { deriveSubagentProgressState, deriveSubagentLiveEntries, deriveSubagentResultEntries, + deriveThreadSubagentHistory, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, @@ -79,6 +89,7 @@ import { formatElapsed, type McpAuthReconnectAction, type ProviderAuthReconnectAction, + type SubagentProgressItem, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { @@ -120,10 +131,11 @@ import { useWsConnectionStatus } from "../rpc/wsConnectionState"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, - draftSourceControlPanelStateKey, + draftRightPanelStateKey, useChatHeaderBottomVarRef, - useSourceControlPanelOpen, } from "../rightPanelLayout"; +import { publishAgentsPanelSource, selectAgentsPanelAgent } from "../agentsPanelStore"; +import { summarizeLiveAgents } from "./chat/agentsPanel.logic"; import { buildTemporaryWorktreeBranchName } from "@threadlines/shared/git"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -192,7 +204,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 +220,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 +343,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,15 +1305,15 @@ 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, - ); - // 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. - const diffPanelOpen = rawSearch.diff === "1"; - const rightPanelEngaged = sourceControlOpen || diffPanelOpen; + const rightPanelStateKey = + routeKind === "draft" && draftId ? draftRightPanelStateKey(draftId) : routeThreadKey; + // The sidebar's tab strip is owned by the route that renders it; the header + // reads the same store so its panel button and activity chip agree with what + // is on screen. The button reflects the sidebar as a whole — it stays pressed + // on any tab, and on the launcher. + const rightPanelTabs = useRightPanelTabs(rightPanelStateKey); + const rightPanelEngaged = rightPanelTabs.visible; + const agentsPanelOpen = rightPanelTabs.activeTab === "agents"; const activeThreadId = activeThread?.id ?? null; const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), @@ -1346,6 +1363,17 @@ export default function ChatView(props: ChatViewProps) { // General Chat threads run in a hidden scratch workspace: source-control, // scripts, and open-in affordances stay hidden even though a project exists. const isGeneralChatThread = activeProject?.kind === "general-chat"; + // Which surfaces this thread's sidebar offers. Resolved from the same inputs + // the route uses, so the header's entry points can never land on a tab the + // strip would refuse to show. + const rightPanelAvailableTabs = useMemo( + () => + availableRightPanelTabs({ + isGeneralChat: isGeneralChatThread, + isDraft: routeKind === "draft" || !isServerThread, + }), + [isGeneralChatThread, isServerThread, routeKind], + ); /** * A picked element becomes a context attached to the message, not text in it. * It is the same shape as a terminal excerpt or a highlighted quote: evidence @@ -1931,6 +1959,13 @@ export default function ChatView(props: ChatViewProps) { }), [activeLatestTurn?.turnId, latestTurnSettled, threadActivities], ); + // The turn-scoped progress above empties when the turn settles. The panel's + // history section and the conversation's receipts both need the thread's whole + // roster, which the same activities answer without the turn filter. + const subagentHistory = useMemo( + () => deriveThreadSubagentHistory(threadActivities), + [threadActivities], + ); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2656,71 +2691,98 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); - const closeRightPanelForRoute = useCallback(() => { - if (!activeThread) { - return; - } - if (routeKind === "draft" && draftId) { + /** + * Writes the sidebar's active tab into the URL for whichever route this chat + * column is rendered by. A null tab is the sidebar hidden or sitting on the + * launcher; the strip state itself lives in the shared tab store, which the + * route reads back on the next render. + */ + const navigateToRightPanelTab = useCallback( + (tab: RightPanelTab | null, diffTarget?: RightPanelDiffTarget | null) => { + if (!activeThread) { + return; + } + const nextSearch = (previous: Record) => + rightPanelTabSearchParams(previous, tab, diffTarget); + if (routeKind === "draft" && draftId) { + void navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(draftId), + replace: true, + search: nextSearch, + }); + return; + } void navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(draftId), + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, replace: true, - search: (previous) => closeRightPanelSearchParams(previous), + search: nextSearch, }); + }, + [activeThread, draftId, environmentId, navigate, routeKind, threadId], + ); + + const openRightPanelTab = useCallback( + (tab: RightPanelTab, diffTarget?: RightPanelDiffTarget | null) => { + if (!activeThread || !rightPanelAvailableTabs.includes(tab)) { + return; + } + focusRightPanelTab(rightPanelStateKey, tab); + navigateToRightPanelTab(tab, diffTarget); + }, + [activeThread, navigateToRightPanelTab, rightPanelAvailableTabs, rightPanelStateKey], + ); + + /** + * The header's activity chip: opens or focuses the Agents tab, and pressing + * it while Agents is the tab on screen puts the sidebar away again. + */ + const onToggleAgentsPanel = useCallback(() => { + if (!activeThread) { return; } - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - replace: true, - search: (previous) => closeRightPanelSearchParams(previous), - }); - }, [activeThread, draftId, environmentId, navigate, routeKind, threadId]); + if (agentsPanelOpen) { + hideRightPanel(rightPanelStateKey); + navigateToRightPanelTab(null); + return; + } + openRightPanelTab("agents"); + }, [ + activeThread, + agentsPanelOpen, + navigateToRightPanelTab, + openRightPanelTab, + rightPanelStateKey, + ]); - const onToggleSourceControl = useCallback(() => { + /** + * The header's one sidebar entry point: it shows and hides the sidebar + * itself. Showing lands on the tab this thread was left on, and on the + * launcher when nothing is open — never on a bare panel. + */ + const onToggleRail = useCallback(() => { if (!activeThread) { return; } if (rightPanelEngaged) { - closeRightPanelForRoute(); + hideRightPanel(rightPanelStateKey); + navigateToRightPanelTab(null); return; } - if (routeKind === "draft" && draftId) { - void navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(draftId), - replace: true, - search: (previous) => ({ - ...stripRightPanelSearchParams(previous), - sourceControl: "1", - }), - }); - return; + const { activeTab, diffTarget } = showRightPanel(rightPanelStateKey, rightPanelAvailableTabs); + if (activeTab !== null) { + navigateToRightPanelTab(activeTab, activeTab === "diff" ? diffTarget : null); } - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - replace: true, - search: (previous) => ({ - ...stripRightPanelSearchParams(previous), - sourceControl: "1", - }), - }); }, [ activeThread, - closeRightPanelForRoute, - draftId, - environmentId, - navigate, + navigateToRightPanelTab, + rightPanelAvailableTabs, rightPanelEngaged, - routeKind, - threadId, + rightPanelStateKey, ]); const envLocked = Boolean( @@ -2814,13 +2876,15 @@ export default function ChatView(props: ChatViewProps) { // in, so a terminal opened behind it would be invisible — close the // panel and let the drawer take the stage. if (opening && shouldUseRightPanelSheet && rightPanelEngaged) { - closeRightPanelForRoute(); + hideRightPanel(rightPanelStateKey); + navigateToRightPanelTab(null); } setTerminalOpen(opening); }, [ activeThreadRef, - closeRightPanelForRoute, + navigateToRightPanelTab, rightPanelEngaged, + rightPanelStateKey, setTerminalOpen, shouldUseRightPanelSheet, terminalState.terminalOpen, @@ -3249,6 +3313,40 @@ 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, + history: subagentHistory, + providerLabel: activeProviderDriver, + turnInFlight: activeTurnInProgress, + threadCwd: gitCwd, + onToggleBackgroundRunTerminal: toggleBackgroundRunTerminal, + onStopBackgroundRun: stopBackgroundRun, + }); + }, [ + activeProviderDriver, + activeThreadId, + activeTurnInProgress, + backgroundRuns, + environmentId, + gitCwd, + stopBackgroundRun, + subagentHistory, + subagentProgress?.items, + toggleBackgroundRunTerminal, + ]); + useEffect(() => () => publishAgentsPanelSource(null), []); + const confirmPendingTerminalKill = useCallback(() => { if (!pendingTerminalKill) return; performCloseTerminal( @@ -4221,7 +4319,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); - onToggleSourceControl(); + onToggleRail(); return; } @@ -4254,7 +4352,7 @@ export default function ChatView(props: ChatViewProps) { runProjectScript, splitTerminal, keybindings, - onToggleSourceControl, + onToggleRail, toggleTerminalVisibility, ]); @@ -6041,17 +6139,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; @@ -6213,33 +6300,22 @@ export default function ChatView(props: ChatViewProps) { const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); + /** A "view diff" link in the conversation opens or retargets the one Diff + * tab, leaving the rest of the strip alone. */ const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread) { return; } onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - search: (previous) => { - const rest = stripRightPanelSearchParams(previous); - return filePath - ? { - ...rest, - diff: "1", - sourceControlReturn: "1", - diffTurnId: turnId, - diffFilePath: filePath, - } - : { ...rest, diff: "1", sourceControlReturn: "1", diffTurnId: turnId }; - }, - }); + const target: RightPanelDiffTarget = { + diffTurnId: turnId, + ...(filePath ? { diffFilePath: filePath } : {}), + }; + retargetRightPanelDiff(rightPanelStateKey, target); + navigateToRightPanelTab("diff", target); }, - [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], + [isServerThread, navigateToRightPanelTab, onDiffPanelOpen, rightPanelStateKey], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. @@ -6275,6 +6351,46 @@ export default function ChatView(props: ChatViewProps) { [activeThread, navigate], ); + /** The closed panel button's live-agent node. */ + const headerLiveAgents = useMemo( + () => + summarizeLiveAgents({ + subagents: subagentProgress?.items ?? EMPTY_SUBAGENT_ITEMS, + backgroundRuns, + }), + [backgroundRuns, subagentProgress?.items], + ); + + /** + * The turn activity row's agent summary. The live items only cover the turn in + * flight, so the thread's durable agent history rides along: it is what every + * settled turn's tracker is drawn from, and the only source at all after a + * reload. + */ + const timelineTurnAgents = useMemo(() => { + const subagents = subagentProgress?.items ?? []; + if (subagents.length === 0 && subagentHistory.length === 0) { + return null; + } + return { subagents, history: subagentHistory }; + }, [subagentHistory, subagentProgress?.items]); + + /** + * The conversation's way into the sidebar: the turn activity row opens or + * focuses the Agents tab, a finished agent's receipt opens it drilled into + * that agent. Selecting before navigating keeps a second receipt press + * working while the tab is already showing. + */ + const onOpenAgentsPanel = useCallback( + (agentThreadId: string | null) => { + selectAgentsPanelAgent(agentThreadId); + if (!agentsPanelOpen) { + openRightPanelTab("agents"); + } + }, + [agentsPanelOpen, openRightPanelTab], + ); + const timelineProposedPlanState = useMemo( () => ({ activePlanId: hasActionableProposedPlan(activeProposedPlan) @@ -6344,39 +6460,29 @@ export default function ChatView(props: ChatViewProps) { terminalAvailable={activeProject !== undefined} terminalOpen={terminalState.terminalOpen} terminalToggleShortcutLabel={terminalToggleShortcutLabel} - sourceControlToggleShortcutLabel={sourceControlPanelShortcutLabel} - sourceControlOpen={rightPanelEngaged && !isGeneralChatThread} + railToggleShortcutLabel={sourceControlPanelShortcutLabel} + railOpen={rightPanelEngaged} sourceControlAvailable={activeProject !== undefined && !isGeneralChatThread} browserAvailable={browserAvailable} browserOpen={browserOpen} onToggleBrowser={handleToggleBrowser} workingTreeDiffStat={workingTreeDiffStat} remoteBehindCount={remoteBehindCount} + liveAgents={headerLiveAgents} fileBrowserAvailable={!isGeneralChatThread} taskProgress={taskProgress} 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} + onToggleRail={onToggleRail} onContinueInProject={ isGeneralChatThread && isServerThread && (activeThread?.messages.length ?? 0) > 0 ? onContinueInProject @@ -6469,6 +6575,8 @@ export default function ChatView(props: ChatViewProps) { searchTarget={timelineSearchTarget} planScrollTarget={planScrollTarget} proposedPlanState={timelineProposedPlanState} + turnAgents={timelineTurnAgents} + onOpenAgentsPanel={onOpenAgentsPanel} /> {/* scroll to bottom button — shown when user has scrolled away from the bottom. diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 00379fd02..1ab81e6de 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,7 +6,6 @@ import { type ContextMenuItem, TurnId } from "@threadlines/contracts"; import type { DiffRenderMode } from "@threadlines/contracts/settings"; import { ChevronDownIcon, - ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, ChevronsDownUpIcon, @@ -261,22 +260,20 @@ interface PendingDiscardDiffFile { interface DiffPanelProps { mode?: DiffPanelMode; - onBackToSourceControl?: () => void; /** * Closes the containing right panel. Surfaced as an in-panel ✕ on phone * widths, where the sheet spans the full screen and the header toggle is - * easy to miss. + * easy to miss. Ignored when embedded: the tab strip owns the dismissal. */ onClose?: () => void; + /** Set when the panel renders inside the sidebar's tab strip, which already + * carries the window chrome and the dismissal. */ + embedded?: boolean; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; -export default function DiffPanel({ - mode = "inline", - onBackToSourceControl, - onClose, -}: DiffPanelProps) { +export default function DiffPanel({ mode = "inline", onClose, embedded = false }: DiffPanelProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); const { resolvedTheme } = useTheme(); @@ -1197,33 +1194,15 @@ export default function DiffPanel({ (diffStatsSummary ?? "All checkpointed changes from this chat") ); - const headerRow = ( + // Embedded, the tab strip above already names this surface and is the way to + // every other one, so the panel adds no header of its own: no title bar, no + // back-to-Source breadcrumb, no row of height spent restating the tab. + const headerRow = embedded ? null : (
- {onBackToSourceControl ? ( - - - - ) : ( - - - - SC - Source Control - - - )} + + + Source + @@ -1560,7 +1539,7 @@ export default function DiffPanel({ ); return ( - +
void) | undefined; + /** Set when the panel renders under the right sidebar's tab strip, which + * already carries the drag region and the window-controls inset. */ + embedded?: boolean; }) { - const { onEscape } = props; + const { embedded = false, onEscape } = props; const handleKeyDown = onEscape && props.mode !== "sheet" ? (event: KeyboardEvent) => { @@ -34,49 +37,40 @@ export function DiffPanelShell(props: { )} onKeyDown={handleKeyDown} > -
-
- {props.header} + {props.header ? ( +
+
+ {props.header} +
-
+ ) : null} {props.children}
); } -export function DiffPanelHeaderSkeleton() { - return ( -
- - -
- ); -} - +/** + * Waiting for the diff says so in one line, on the panel's gutter. It used to be + * a framed pane of skeleton bars, which drew a whole fake document over a delay + * that is usually shorter than reading the word "loading" -- and made the panel + * look like an embedded app rather than a sidebar. + */ export function DiffPanelLoadingState(props: { label: string }) { return ( -
-
-
- - -
-
-
- - - - - -
- {props.label} -
-
+
+ {props.label}
); } diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 7d9b5260a..2cc17c98b 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -3,22 +3,26 @@ import { type CSSProperties, type ReactNode } from "react"; import { isElectron } from "../env"; import { cn } from "../lib/utils"; import { - RIGHT_PANEL_INLINE_SIDEBAR_MIN_WIDTH, + RIGHT_PANEL_RAIL_SHEET_CLASS_NAME, + RIGHT_PANEL_RAIL_WIDTH, RIGHT_PANEL_SHEET_BACKDROP_CLASS_NAME, RIGHT_PANEL_SHEET_CLASS_NAME, RIGHT_PANEL_SHEET_VIEWPORT_CLASS_NAME, - RIGHT_PANEL_SOURCE_CONTROL_SHEET_CLASS_NAME, } from "../rightPanelLayout"; import { Sheet, SheetPopup } from "./ui/sheet"; +const SHEET_CLASS_NAME_BY_SIZE = { + default: RIGHT_PANEL_SHEET_CLASS_NAME, + rail: RIGHT_PANEL_RAIL_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 ( {props.children} diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index d666360a4..5bf4c43da 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -64,7 +64,7 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; -import { ScrollArea } from "../ui/scroll-area"; +import { MINI_HORIZONTAL_SCROLLBAR_CLASS, ScrollArea } from "../ui/scroll-area"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { RotateDeviceIcon } from "../Icons"; @@ -670,7 +670,7 @@ export function BrowserPanel({ observeContentResize contentClassName="h-full" horizontalWheelScroll - className="min-w-0 flex-1 self-stretch [&_[data-slot=scroll-area-scrollbar][data-orientation=horizontal]]:mx-1 [&_[data-slot=scroll-area-scrollbar][data-orientation=horizontal]]:my-0.5 [&_[data-slot=scroll-area-scrollbar][data-orientation=horizontal]]:h-1 [&_[data-slot=scroll-area-scrollbar][data-orientation=horizontal]]:opacity-100" + className={cn("min-w-0 flex-1 self-stretch", MINI_HORIZONTAL_SCROLLBAR_CLASS)} >
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..e285382ff --- /dev/null +++ b/apps/web/src/components/chat/AgentsPanel.browser.tsx @@ -0,0 +1,1717 @@ +import "../../index.css"; + +import { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import { page, userEvent } 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, + ThreadSubagentHistoryEntry, +} from "../../session-logic"; +import { resetAgentsPanelSourceForTests } from "../../agentsPanelStore"; +import { AgentsPanel } from "./AgentsPanel"; +import { ChatRightPanel } from "../ChatRightPanel"; +import { buildRightPanelLauncherStates } from "./rightPanelLauncherState"; +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, + }; +} + +function buildHistoryEntry(entry: { + item: SubagentProgressItem; + resultBody?: string | null; +}): ThreadSubagentHistoryEntry { + return { item: entry.item, resultBody: entry.resultBody ?? null }; +} + +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(() => { + // The drill-in selection is shared module state now, so each case starts + // from the tree rather than whatever the last one drilled into. + resetAgentsPanelSourceForTests(); + 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(); + + expect(document.querySelector("[data-agents-panel='tree']")).not.toBeNull(); + // The objective is no longer a line of its own; the row still carries it. + expect( + document + .querySelector("[data-agent-branch='true'] > button, [data-agent-branch='true'] > div") + ?.getAttribute("title"), + ).toBe("Sweep the router for panel wiring"); + } finally { + await mounted.unmount(); + } + }); + + /** The instruction block above the drilled-in thread, if there is one. */ + function drilledInInstructionText(): string | null { + return ( + document.querySelector("[data-subagent-transcript-instruction='true']")?.textContent ?? null + ); + } + + function headerObjectiveText(): string | null { + return document.querySelector("[data-subagent-inspector-goal='true']")?.textContent ?? null; + } + + it("stands the objective in as the instruction rather than saying it twice", async () => { + // The default transcript opens on the agent's own work, with no leading + // message: the shape a forked Codex child arrives in. + 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(drilledInInstructionText()).toContain("Sweep the router for panel wiring"); + expect(headerObjectiveText()).toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("drops the header objective when a leading message already carries it", async () => { + // A Claude child: its first stored record is the spawn prompt the row's + // objective was derived from, so the header would only repeat it. + transcriptRpcMock.mockResolvedValue({ + entries: [ + { + role: "user", + text: "Sweep the router for panel wiring. Report back with a list.", + toolUses: [], + }, + { role: "assistant", text: "Walked the route files.", toolUses: [] }, + ], + truncated: false, + offset: 0, + totalEntries: 2, + }); + 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(/Report back with a list/)).toBeVisible(); + + expect(drilledInInstructionText()).toContain("Report back with a list"); + expect(headerObjectiveText()).toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("keeps the header objective when the instruction says something else", async () => { + transcriptRpcMock.mockResolvedValue({ + entries: [ + { role: "user", text: "Continue where the last agent stopped.", toolUses: [] }, + { role: "assistant", text: "Walked the route files.", toolUses: [] }, + ], + truncated: false, + offset: 0, + totalEntries: 2, + }); + 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("Continue where the last agent stopped.")).toBeVisible(); + + expect(headerObjectiveText()).toContain("Sweep the router for panel wiring"); + } finally { + await mounted.unmount(); + } + }); + + it("says so only when the thread has never run an agent", async () => { + const mounted = await renderPanel(); + + try { + await expect.element(page.getByText(/No agents yet\./u)).toBeVisible(); + expect( + document + .querySelector("[data-agents-panel-empty='true']") + ?.getAttribute("data-agents-panel-empty-state"), + ).toBe("never-ran"); + } finally { + await mounted.unmount(); + } + }); + + it("waits on an in-flight turn instead of claiming the thread never ran an agent", async () => { + // The turn is dispatched but the provider has not been handed off yet, so + // there is nothing to list and agents may still be spawned. + const mounted = await renderPanel({ turnInFlight: true }); + + try { + await expect.element(page.getByText(/Waiting on the turn\./u)).toBeVisible(); + expect(document.body.textContent).not.toContain("No agents yet"); + expect( + document + .querySelector("[data-agents-panel-empty='true']") + ?.getAttribute("data-agents-panel-empty-state"), + ).toBe("waiting"); + } finally { + await mounted.unmount(); + } + }); + + it("keeps finished agents listed under Earlier once the live items empty", async () => { + // The turn has settled, so the live source is empty and only the thread's + // durable history is left. This is the state the panel used to collapse in. + const mounted = await renderPanel({ + subagents: [], + history: [ + buildHistoryEntry({ + item: buildSubagent({ + id: "agent-old", + agentThreadId: "agent-old", + transcriptAgentId: "agent-old", + label: "Router sweep", + status: "completed", + statusLabel: "Completed", + model: "claude-opus-5", + reasoningEffort: "high", + updatedAt: "2026-08-11T09:00:00.000Z", + }), + resultBody: "- The route never mounted the panel.\nMore detail below.", + }), + ], + }); + + try { + await expect.element(page.getByText("Router sweep")).toBeVisible(); + expect(document.querySelector("[data-agents-panel-empty='true']")).toBeNull(); + expect(document.querySelector("[data-agents-panel-earlier='true']")?.textContent).toBe( + "Earlier", + ); + // The report leads, with the markdown that opened it stripped. + await expect.element(page.getByText("The route never mounted the panel.")).toBeVisible(); + const meta = document.querySelector("[data-agent-branch-meta='true']")?.textContent ?? ""; + expect(meta).toContain("claude-opus-5"); + expect(meta).toContain("high"); + } finally { + await mounted.unmount(); + } + }); + + it("branches the live turn off a trunk and files the history flat, on one gutter", async () => { + const mounted = await render( +
+ +
, + ); + + try { + await expect.element(page.getByText("Panel audit")).toBeVisible(); + + const panel = document.querySelector("[data-agents-panel='tree']")!.getBoundingClientRect(); + const rows = [...document.querySelectorAll("[data-agent-branch='true']")] as HTMLElement[]; + // Both the live turn and the Earlier history render through this row, in + // its two shapes: the live turn branches, the record is filed flat. + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.getAttribute("data-agent-branch-variant"))).toEqual([ + "branch", + "flat", + ]); + + for (const row of rows) { + const name = row.querySelector("[data-agent-branch-meta='true']") + ?.previousElementSibling as HTMLElement; + const meta = row.querySelector("[data-agent-branch-meta='true']") as HTMLElement; + expect(meta).not.toBeNull(); + + // Under the name, not beside it. + expect(meta.getBoundingClientRect().top).toBeGreaterThanOrEqual( + name.getBoundingClientRect().bottom - 1, + ); + // Which is the point: `model · effort · tokens` fits at 330px now instead + // of being cut off by a 45% cap. + expect(meta.scrollWidth).toBeLessThanOrEqual(meta.clientWidth + 1); + // Meta and the signal line read at one size, as the left sidebar's do. + expect(getComputedStyle(meta).fontSize).toBe("11px"); + expect( + getComputedStyle(row.querySelector("[data-agent-branch-output='true']")!).fontSize, + ).toBe("11px"); + // Three lines, and no more: name, meta, and one signal line. The + // objective used to take a fourth. + expect(row.querySelector("[data-agent-branch-task='true']")).toBeNull(); + expect(row.getBoundingClientRect().height).toBeLessThanOrEqual(66); + } + + const [liveRow, historyRow] = rows as [HTMLElement, HTMLElement]; + + // The live row hangs off the trunk on a slim tree gutter, and its arm still + // meets the status dot after the row's padding tightened. + const arm = ( + liveRow.querySelector("[data-agent-branch-arm='true']") as HTMLElement + ).getBoundingClientRect(); + const node = ( + liveRow.querySelector("[data-agent-branch-node='true']") as HTMLElement + ).getBoundingClientRect(); + expect(Math.abs((arm.top + arm.bottom) / 2 - (node.top + node.bottom) / 2)).toBeLessThan(1.5); + expect(node.left - panel.left).toBeLessThanOrEqual(22); + + // The record does not: no arm, no indent, content on the panel's own 12px + // gutter, level with the Earlier label above it. + const earlier = document.querySelector("[data-agents-panel-earlier='true']")!; + const earlierBox = earlier.getBoundingClientRect(); + expect(historyRow.querySelector("[data-agent-branch-arm='true']")).toBeNull(); + const historyName = historyRow.querySelector("[data-agent-branch-meta='true']")! + .previousElementSibling as HTMLElement; + const historyNameLeft = historyName.getBoundingClientRect().left - panel.left; + expect(historyNameLeft).toBeCloseTo(12, 0); + expect( + earlier.querySelector("span.truncate")!.getBoundingClientRect().left - panel.left, + ).toBeCloseTo(historyNameLeft, 0); + + // Its meta is `model · effort · tokens`; when it ran sits on the row's own + // right edge instead, where a left thread row puts it. + const historyMeta = historyRow.querySelector("[data-agent-branch-meta='true']")!; + expect(historyMeta.textContent).toBe("gpt-5.6-sol · high"); + const time = historyRow.querySelector("[data-agent-branch-time='true']") as HTMLElement; + expect(time.textContent).toMatch(/ago$/u); + expect(panel.right - time.getBoundingClientRect().right).toBeLessThanOrEqual(34); + + // The trunk stops where the history starts: alive branches, done is filed. + const trunk = document.querySelector("[data-agents-panel-trunk='true']")!; + expect(trunk.getBoundingClientRect().bottom).toBeLessThanOrEqual(earlierBox.top + 0.5); + } finally { + await mounted.unmount(); + } + }); + + it("shows a disclosure at the right edge of a row that opens something, on hover", async () => { + const mounted = await renderPanel({ + subagents: [ + // Transcript-backed, so clicking it drills in. + buildSubagent({ label: "Router sweep" }), + // No agent thread, so there is nothing to open and nothing to promise. + buildSubagent({ + id: "agent-mute", + agentThreadId: null, + transcriptAgentId: null, + label: "Unspawned sweep", + }), + ], + }); + + try { + await expect.element(page.getByText("Unspawned sweep")).toBeVisible(); + const rows = [...document.querySelectorAll("[data-agent-branch='true']")] as HTMLElement[]; + const [openable, inert] = rows as [HTMLElement, HTMLElement]; + + const chevron = openable.querySelector("[data-agent-branch-disclosure='true']"); + expect(chevron).not.toBeNull(); + expect(inert.querySelector("[data-agent-branch-disclosure='true']")).toBeNull(); + + // Transparent until the row is hovered or focused: an always-drawn icon on + // every row would be noise, and colour is the only thing that changes. + const transparent = /(?:,|\/)\s*0\s*\)/u; + expect(getComputedStyle(chevron!).color).toMatch(transparent); + await page.getByRole("button", { name: "Open Router sweep transcript" }).hover(); + await vi.waitFor(() => { + if (transparent.test(getComputedStyle(chevron!).color)) { + throw new Error("The disclosure never appeared on hover."); + } + }); + // And it sits at the row's right edge, not in the text. + expect( + openable.getBoundingClientRect().right - chevron!.getBoundingClientRect().right, + ).toBeLessThanOrEqual(16); + } finally { + await mounted.unmount(); + } + }); + + it("orders the history newest first and drills into a history-only agent", async () => { + const mounted = await renderPanel({ + subagents: [], + history: [ + buildHistoryEntry({ + item: buildSubagent({ + id: "agent-older", + agentThreadId: "agent-older", + transcriptAgentId: "agent-older", + label: "Older sweep", + status: "completed", + statusLabel: "Completed", + updatedAt: "2026-08-11T08:00:00.000Z", + }), + }), + buildHistoryEntry({ + item: buildSubagent({ + id: "agent-newer", + agentThreadId: "agent-newer", + transcriptAgentId: "agent-newer", + label: "Newer sweep", + status: "completed", + statusLabel: "Completed", + updatedAt: "2026-08-11T09:30:00.000Z", + }), + }), + ], + }); + + try { + await expect.element(page.getByText("Newer sweep")).toBeVisible(); + const order = [...document.querySelectorAll("[data-agent-branch='true']")] + .map((row) => row.textContent ?? "") + .join("|"); + expect(order.indexOf("Newer sweep")).toBeLessThan(order.indexOf("Older sweep")); + + // A receipt for a long-finished agent has to resolve against the history, + // which is the only place that agent still exists. + await page.getByRole("button", { name: "Open Older sweep transcript" }).click(); + await expect.element(page.getByText("Walked the route files.")).toBeVisible(); + expect(document.querySelector("[data-agents-panel='drill-in']")).not.toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("lets a live agent win over its own history record", async () => { + const mounted = await renderPanel({ + subagents: [buildSubagent({ label: "Router sweep", telemetry: null })], + history: [ + buildHistoryEntry({ + item: buildSubagent({ label: "Router sweep", status: "completed" }), + resultBody: "Done.", + }), + ], + }); + + try { + const rows = [...document.querySelectorAll("[data-agent-branch='true']")]; + expect(rows.length).toBe(1); + expect(rows[0]?.getAttribute("data-agent-branch-status")).toBe("running"); + expect(document.querySelector("[data-agents-panel-earlier='true']")).toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("folds a long tool run in the drilled-in transcript into one receipt that opens in place", async () => { + const toolUse = (name: string) => ({ name, summary: `${name}: src/thing.ts` }); + transcriptRpcMock.mockResolvedValue({ + entries: [ + { role: "assistant", text: "Looking for the handler.", toolUses: [] }, + { + role: "assistant", + text: "", + toolUses: [toolUse("Read"), toolUse("Read"), toolUse("Read"), toolUse("Edit")], + at: "2026-08-11T10:00:00.000Z", + }, + { + role: "assistant", + text: "", + toolUses: [toolUse("Bash")], + at: "2026-08-11T10:01:10.000Z", + }, + { role: "assistant", text: "Fixed it.", toolUses: [] }, + ], + truncated: false, + offset: 0, + totalEntries: 4, + }); + + const mounted = await renderPanel({ + subagents: [ + buildSubagent({ label: "Router sweep", status: "completed", statusLabel: "Completed" }), + ], + }); + + try { + await page.getByRole("button", { name: "Open Router sweep transcript" }).click(); + + // The agent's prose always renders; the machinery between it does not. + await expect.element(page.getByText("Looking for the handler.")).toBeVisible(); + await expect.element(page.getByText("Fixed it.")).toBeVisible(); + + const receipt = await vi.waitUntil(() => + document.querySelector("[data-subagent-transcript-tool-run-toggle='true']"), + ); + expect(receipt.textContent).toContain("5 actions"); + expect(receipt.textContent).toContain("Read ×3"); + expect(receipt.getAttribute("aria-expanded")).toBe("false"); + expect(document.querySelector("[data-subagent-transcript-entry='tool']")).toBeNull(); + + receipt.click(); + + await vi.waitFor(() => { + expect(receipt.getAttribute("aria-expanded")).toBe("true"); + expect(document.querySelector("[data-subagent-transcript-entry='tool']")).not.toBeNull(); + }); + } finally { + await mounted.unmount(); + } + }); + + it("fronts even a short tool run with a receipt, so the drill-in is prose and receipts", async () => { + transcriptRpcMock.mockResolvedValue({ + entries: [ + { role: "assistant", text: "Checking two things.", toolUses: [] }, + { + role: "assistant", + text: "", + toolUses: [ + { name: "Read", summary: "Read: a.ts" }, + { name: "Grep", summary: "Grep: handler" }, + ], + }, + ], + truncated: false, + offset: 0, + totalEntries: 2, + }); + + const mounted = await renderPanel({ + subagents: [ + buildSubagent({ label: "Router sweep", status: "completed", statusLabel: "Completed" }), + ], + }); + + try { + await page.getByRole("button", { name: "Open Router sweep transcript" }).click(); + await expect.element(page.getByText("Checking two things.")).toBeVisible(); + + // The run is folded, not inlined, and the rows only arrive on request. + await vi.waitFor(() => { + expect( + document.querySelector("[data-subagent-transcript-tool-run-toggle='true']"), + ).not.toBeNull(); + }); + await expect.element(page.getByText("2 actions")).toBeVisible(); + expect(document.querySelector("[data-subagent-transcript-entry='tool']")).toBeNull(); + + await page.getByRole("button", { name: /2 actions/u }).click(); + await expect.element(page.getByText("handler")).toBeVisible(); + } finally { + await mounted.unmount(); + } + }); + + it("marks a spawned agent with the thread provider's glyph but leaves runs their tag", async () => { + const mounted = await renderPanel({ + subagents: [buildSubagent({ label: "Router sweep" })], + backgroundRuns: [TERMINAL_RUN], + providerLabel: "claudeAgent", + }); + + try { + await expect.element(page.getByText("Router sweep")).toBeVisible(); + + const rows = [...document.querySelectorAll("[data-agent-branch='true']")]; + const subagentRow = rows.find( + (row) => row.getAttribute("data-agent-branch-kind") === "subagent", + ); + const runRow = rows.find((row) => row.getAttribute("data-agent-branch-kind") === "run"); + expect(subagentRow?.querySelector("[data-agent-branch-provider='true'] svg")).not.toBeNull(); + expect(runRow?.querySelector("[data-agent-branch-provider='true']")).toBeNull(); + expect(runRow?.querySelector("[data-agent-branch-tag='true']")?.textContent).toBe("terminal"); + } finally { + await mounted.unmount(); + } + }); + + it("keeps the sidebar's tab strip above a drilled-in transcript", async () => { + const onSelectTab = vi.fn(); + const mounted = await render( +
+ + + +
, + ); + + 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(); + + // The tabs stay reachable from inside the transcript. + const sourceTab = page.getByRole("tab", { name: "Source" }); + await expect.element(sourceTab).toBeVisible(); + await sourceTab.click(); + expect(onSelectTab).toHaveBeenCalledWith("sourceControl"); + } finally { + await mounted.unmount(); + } + }); + + it("shows the launcher as flat rows on the panel gutter, and opens one from a row", async () => { + const onSelectTab = vi.fn(); + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByText("Working tree changes on this branch.")).toBeVisible(); + await expect.element(page.getByText("Subagents and background runs.")).toBeVisible(); + // No tab is open, so no surface is mounted behind the launcher. + expect(document.querySelector("[data-testid='never-rendered']")).toBeNull(); + + const panel = document + .querySelector("[data-chat-right-panel='true']")! + .getBoundingClientRect(); + const rows = [ + ...document.querySelectorAll("[data-right-panel-launcher-row]"), + ] as HTMLElement[]; + expect(rows).toHaveLength(3); + // "Open a panel" sits on the same gutter the rows do. + const sectionLabelText = document.querySelector( + "[data-right-panel-launcher='true'] span.truncate", + )!; + const labelLeft = sectionLabelText.getBoundingClientRect().left - panel.left; + expect(labelLeft).toBeCloseTo(12, 0); + + for (const row of rows) { + const box = row.getBoundingClientRect(); + // Full-width rows in the left sidebar's rhythm, not a grid of tiles: the + // row spans the panel and its own height is a row's, not a card's. + expect(Math.round(box.width)).toBe(Math.round(panel.width)); + expect(box.height).toBeGreaterThanOrEqual(38); + expect(box.height).toBeLessThanOrEqual(46); + // Nothing boxed: structure comes from the dividers between rows. + expect(getComputedStyle(row).borderTopWidth).toBe("0px"); + expect(getComputedStyle(row).borderRadius).toBe("0px"); + // Row content opens on the panel's one gutter, level with the label. + const icon = row.firstElementChild as HTMLElement; + expect(icon.getBoundingClientRect().left - panel.left).toBeCloseTo(labelLeft, 0); + // A description is read, not guessed at: it fits its line whole. + const description = row.querySelector("span > span:last-child") as HTMLElement; + expect(description.textContent).toMatch(/\.$/u); + expect(description.scrollWidth).toBeLessThanOrEqual(description.clientWidth + 1); + } + + (document.querySelector("[data-right-panel-launcher-row='diff']") as HTMLElement).click(); + expect(onSelectTab).toHaveBeenCalledWith("diff"); + } finally { + await mounted.unmount(); + } + }); + + it("dims the launcher rows whose surfaces are empty, and opens them anyway", async () => { + const onSelectTab = vi.fn(); + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByText("No uncommitted changes.")).toBeVisible(); + await expect.element(page.getByText("No changes to review.")).toBeVisible(); + await expect.element(page.getByText("No agents yet.")).toBeVisible(); + + const rows = [ + ...document.querySelectorAll("[data-right-panel-launcher-row]"), + ] as HTMLElement[]; + const foregroundLabel = getComputedStyle(document.body).color; + // Source is never dimmed by a clean tree -- branching, committing and + // opening a pull request are all reasons to go there with nothing + // changed -- so only the two surfaces that really are empty are. + const dimmedRows = rows.filter( + (row) => row.dataset.rightPanelLauncherRow !== "sourceControl", + ); + expect(dimmedRows).toHaveLength(2); + expect( + rows.find((row) => row.dataset.rightPanelLauncherRow === "sourceControl")?.dataset + .rightPanelLauncherRowEmpty, + ).toBeUndefined(); + for (const row of dimmedRows) { + expect(row.dataset.rightPanelLauncherRowEmpty).toBe("true"); + // Dimmed, not disabled: nothing here says the row cannot be used. + expect(row.hasAttribute("disabled")).toBe(false); + expect(row.getAttribute("aria-disabled")).toBeNull(); + expect(getComputedStyle(row).pointerEvents).not.toBe("none"); + expect(getComputedStyle(row).textDecorationLine).toBe("none"); + const label = row.querySelector("span > span:first-child") as HTMLElement; + expect(getComputedStyle(label).color).not.toBe(foregroundLabel); + } + + // Every dimmed row still opens its surface. + for (const row of rows) { + row.click(); + } + expect(onSelectTab.mock.calls.map((call) => call[0])).toEqual([ + "sourceControl", + "diff", + "agents", + ]); + } finally { + await mounted.unmount(); + } + }); + + it("reports the working tree and the thread's agents on the launcher's rows", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + // Source and Diff open onto the same working tree, so they report it alike. + await expect.element(page.getByText("12 files changed.").first()).toBeVisible(); + expect(document.querySelectorAll("[data-right-panel-launcher-row-empty]")).toHaveLength(0); + await expect.element(page.getByText("1 of 2 agents running.")).toBeVisible(); + + for (const row of [ + ...document.querySelectorAll("[data-right-panel-launcher-row]"), + ] as HTMLElement[]) { + // A count is read, not guessed at: it fits its line whole at 330px. + const description = row.querySelector("span > span:last-child") as HTMLElement; + expect(description.scrollWidth).toBeLessThanOrEqual(description.clientWidth + 1); + } + } finally { + await mounted.unmount(); + } + }); + + it("never dims a Diff tab that is pointed at a file, even on a clean tree", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + const diffRow = document.querySelector( + "[data-right-panel-launcher-row='diff']", + ) as HTMLElement; + expect(diffRow.dataset.rightPanelLauncherRowEmpty).toBeUndefined(); + await expect.element(page.getByText("Review this thread's diff.")).toBeVisible(); + // The tree really is clean, and Source reports that without dimming for it. + expect( + (document.querySelector("[data-right-panel-launcher-row='sourceControl']") as HTMLElement) + .dataset.rightPanelLauncherRowEmpty, + ).toBeUndefined(); + } finally { + await mounted.unmount(); + } + }); + + it("keeps Diff lit after a commit, when only the working tree went quiet", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + const diffRow = document.querySelector( + "[data-right-panel-launcher-row='diff']", + ) as HTMLElement; + expect(diffRow.dataset.rightPanelLauncherRowEmpty).toBeUndefined(); + // The longest line the launcher can produce still fits its row whole. + const description = diffRow.querySelector("span > span:last-child") as HTMLElement; + expect(description.textContent).toBe("No uncommitted changes, 6 turns to review."); + expect(description.scrollWidth).toBeLessThanOrEqual(description.clientWidth + 1); + // Source reports the quiet tree, and stays lit for the branch and commit + // controls that are the reason to open it after a commit. + expect( + (document.querySelector("[data-right-panel-launcher-row='sourceControl']") as HTMLElement) + .dataset.rightPanelLauncherRowEmpty, + ).toBeUndefined(); + } finally { + await mounted.unmount(); + } + }); + + it("offers every surface in the + menu and marks the ones already open", async () => { + const onSelectTab = vi.fn(); + const mounted = await render( +
+ +
+ +
, + ); + + try { + await page.getByRole("button", { name: "Open panel" }).click(); + + const sourceItem = await vi.waitFor(() => { + const item = document.querySelector("[data-right-panel-menu-tab='sourceControl']"); + if (!item) throw new Error("The + menu never listed Source."); + return item as HTMLElement; + }); + // Diff is not a surface this thread has, so it is absent entirely. + expect(document.querySelector("[data-right-panel-menu-tab='diff']")).toBeNull(); + // An already-open surface stays listed, marked, and focuses its tab. + expect( + document + .querySelector("[data-right-panel-menu-tab='agents']") + ?.getAttribute("data-right-panel-menu-tab-open"), + ).toBe("true"); + expect(sourceItem.getAttribute("data-right-panel-menu-tab-open")).toBeNull(); + + sourceItem.click(); + expect(onSelectTab).toHaveBeenCalledWith("sourceControl"); + } finally { + await mounted.unmount(); + } + }); + + it("dims an empty Diff entry in the + menu without disabling it", async () => { + const onSelectTab = vi.fn(); + const mounted = await render( +
+ +
+ +
, + ); + + try { + await page.getByRole("button", { name: "Open panel" }).click(); + + const diffItem = await vi.waitFor(() => { + const item = document.querySelector("[data-right-panel-menu-tab='diff']"); + if (!item) throw new Error("The + menu never listed Diff."); + return item as HTMLElement; + }); + const agentsItem = document.querySelector( + "[data-right-panel-menu-tab='agents']", + ) as HTMLElement; + + expect(diffItem.dataset.rightPanelMenuTabEmpty).toBe("true"); + expect(agentsItem.dataset.rightPanelMenuTabEmpty).toBeUndefined(); + expect(getComputedStyle(diffItem).color).not.toBe(getComputedStyle(agentsItem).color); + // Empty is only a visual state: opening Diff still reaches its own empty + // surface, where the fuller explanation belongs. + expect(diffItem.hasAttribute("disabled")).toBe(false); + expect(diffItem.getAttribute("aria-disabled")).toBeNull(); + expect(getComputedStyle(diffItem).pointerEvents).not.toBe("none"); + + diffItem.click(); + expect(onSelectTab).toHaveBeenCalledWith("diff"); + } finally { + await mounted.unmount(); + } + }); + + it("labels every tab while they fit, and drops all the labels rather than scrolling", async () => { + // Both panel widths that matter -- the 330px default and the 272px floor -- + // and each of them twice: as the panel has the row to itself, and with the + // row padded clear of the Windows controls cluster (~154px), which is the + // tightest this strip ever gets and the case that used to lose sight of the + // tab you came from behind a scroll. Which side of the boundary a width falls + // on is measured, not assumed; the point is that both behaviours hold. + for (const width of [330, 272]) { + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByRole("tab", { name: "Source" })).toBeVisible(); + const strip = document.querySelector("[data-right-panel-strip='true']") as HTMLElement; + const row = document.querySelector("[data-right-panel-tabs-row='true']") as HTMLElement; + const viewport = row.querySelector('[data-slot="scroll-area-viewport"]') as HTMLElement; + const plus = document.querySelector("[data-right-panel-add-tab='true']") as HTMLElement; + const mode = () => strip.getAttribute("data-right-panel-strip-mode"); + const tabs = () => + [...document.querySelectorAll("[data-right-panel-tab]")] as HTMLElement[]; + const expectPlusUsable = () => { + const box = plus.getBoundingClientRect(); + const usableRight = + row.getBoundingClientRect().right - parseFloat(getComputedStyle(row).paddingRight); + expect(box.right).toBeLessThanOrEqual(usableRight + 0.5); + const hit = document.elementFromPoint( + (box.left + box.right) / 2, + (box.top + box.bottom) / 2, + ); + expect(plus.contains(hit)).toBe(true); + }; + + // The panel with the row to itself: three whole labels, nothing scrolling, + // and the + parked directly after the last tab rather than out at the edge. + // True at the floor as well as the default -- collapsing is not a width + // threshold, it is whether these particular labels fit. + expect(mode()).toBe("labels"); + const labelledHeight = strip.getBoundingClientRect().height; + const labelledWidths = tabs().map((tab) => Math.round(tab.getBoundingClientRect().width)); + expect( + [...document.querySelectorAll("[data-right-panel-tab] [role='tab']")].map( + (tab) => tab.textContent, + ), + ).toEqual(["Source", "Diff", "Agents"]); + for (const tab of tabs()) { + const label = tab.querySelector("[data-right-panel-tab-label]") as HTMLElement; + expect(label.scrollWidth).toBeLessThanOrEqual(label.clientWidth + 1); + expect(getComputedStyle(label).textOverflow).toBe("clip"); + } + expect(viewport.scrollWidth - viewport.clientWidth).toBe(0); + expect( + plus.getBoundingClientRect().left - tabs().at(-1)!.getBoundingClientRect().right, + ).toBeLessThan(8); + expectPlusUsable(); + + // Now the row the window controls take their cut of. Two labelled tabs + // already do not fit; the answer is every label, not a scrollbar. + row.style.paddingRight = "154px"; + await vi.waitFor(() => { + if (mode() !== "icons") { + throw new Error("The strip never collapsed its labels."); + } + }); + + // All of them, not just the inactive ones: one labelled tab beside two + // glyphs reads as three different kinds of thing. + expect(document.querySelectorAll("[data-right-panel-tab-label]")).toHaveLength(0); + expect(document.querySelectorAll("[data-right-panel-tab-icon-only='true']")).toHaveLength( + 3, + ); + // And nothing is abbreviated on the way: the name is a tooltip, or nowhere. + expect( + tabs().map((tab) => tab.querySelector("[role='tab']")!.getAttribute("aria-label")), + ).toEqual(["Source", "Diff", "Agents"]); + for (const tab of tabs()) { + const box = tab.getBoundingClientRect(); + // Square-ish: the glyph and its padding, nothing else. + expect(box.width).toBeGreaterThanOrEqual(24); + expect(box.width).toBeLessThanOrEqual(34); + expect(Math.abs(box.width - box.height)).toBeLessThanOrEqual(6); + expect(tab.scrollWidth).toBeLessThanOrEqual(tab.clientWidth + 1); + } + const iconWidths = tabs().map((tab) => Math.round(tab.getBoundingClientRect().width)); + const total = (widths: ReadonlyArray) => + widths.reduce((sum, next) => sum + next, 0); + expect(total(iconWidths)).toBeLessThan(total(labelledWidths)); + // The + is still the anchored, clickable control it was, and the strip is + // the same height: collapsing is a width decision, not a layout one. + expectPlusUsable(); + expect(strip.getBoundingClientRect().height).toBeCloseTo(labelledHeight, 1); + + // Give the room back and the labels come back with it, unchanged. + row.style.paddingRight = ""; + await vi.waitFor(() => { + if (mode() !== "labels") { + throw new Error("The strip never put its labels back."); + } + }); + expect(tabs().map((tab) => Math.round(tab.getBoundingClientRect().width))).toEqual( + labelledWidths, + ); + } finally { + await mounted.unmount(); + } + } + }); + + it("crosses the label boundary once per crossing rather than oscillating on it", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByRole("tab", { name: "Source" })).toBeVisible(); + const strip = document.querySelector("[data-right-panel-strip='true']") as HTMLElement; + const row = document.querySelector("[data-right-panel-tabs-row='true']") as HTMLElement; + const mode = () => strip.getAttribute("data-right-panel-strip-mode"); + // The boundary is computed from the strip's own inputs -- the labelled row + // it measures against and the width the anchored controls take out of the + // row -- rather than guessed at, so this test does not encode a font. + const labelled = (row.querySelector("[data-right-panel-tabs-measure='true']") as HTMLElement) + .offsetWidth; + const actions = (row.querySelector("[data-right-panel-strip-actions='true']") as HTMLElement) + .offsetWidth; + const paddingLeaving = (available: number) => + `${row.clientWidth - parseFloat(getComputedStyle(row).paddingLeft) - actions - available}px`; + const settle = async () => { + for (let frame = 0; frame < 3; frame += 1) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve(null)); + }); + } + }; + + let flips = 0; + const observer = new MutationObserver(() => { + flips += 1; + }); + observer.observe(strip, { attributeFilter: ["data-right-panel-strip-mode"] }); + try { + expect(mode()).toBe("labels"); + + // Four pixels short: the labels go, once. + row.style.paddingRight = paddingLeaving(labelled - 4); + await vi.waitFor(() => { + if (mode() !== "icons") { + throw new Error("The strip never collapsed its labels."); + } + }); + expect(flips).toBe(1); + + // Four pixels back the other way -- a drag jittering on the boundary. + // They fit again by the strict measure, and the strip stays put anyway: + // that gap is the hysteresis, and it is what stops the flicker. + row.style.paddingRight = paddingLeaving(labelled + 4); + await settle(); + expect(mode()).toBe("icons"); + expect(flips).toBe(1); + + // Real headroom does bring them back, and once. + row.style.paddingRight = paddingLeaving(labelled + 20); + await vi.waitFor(() => { + if (mode() !== "labels") { + throw new Error("Real headroom never brought the labels back."); + } + }); + await settle(); + expect(flips).toBe(2); + } finally { + observer.disconnect(); + } + } finally { + await mounted.unmount(); + } + }); + + it("names an icon-only tab with a styled tooltip that arrives with the pointer", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + const strip = await vi.waitFor(() => { + const found = document.querySelector("[data-right-panel-strip='true']"); + if (found?.getAttribute("data-right-panel-strip-mode") !== "icons") { + throw new Error("The strip never collapsed to icons at 200px."); + } + return found as HTMLElement; + }); + const tabButton = strip.querySelector( + "[data-right-panel-tab='diff'] [role='tab']", + ) as HTMLElement; + // The app's own tooltip, not the unstyled OS one a `title` would give. + expect(tabButton.getAttribute("title")).toBeNull(); + + await page.getByRole("tab", { name: "Diff" }).hover(); + // No dwell: on a tab with no label, waiting reads as an unnamed control. + const tooltip = await vi.waitFor(() => { + const popup = document.querySelector('[data-slot="tooltip-popup"]'); + if (popup === null) { + throw new Error("Hovering an icon-only tab never named it."); + } + return popup as HTMLElement; + }); + expect(tooltip.textContent).toBe("Diff"); + } finally { + await mounted.unmount(); + } + }); + + it("leaves a labelled tab's name on the tab rather than in a tooltip", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByRole("tab", { name: "Source" })).toBeVisible(); + await page.getByRole("tab", { name: "Source" }).hover(); + for (let frame = 0; frame < 4; frame += 1) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve(null)); + }); + } + // The label is right there; a tooltip repeating it is noise. + expect(document.querySelector('[data-slot="tooltip-popup"]')).toBeNull(); + } finally { + await mounted.unmount(); + } + }); + + it("brings the active tab into view when the row is scrolled past it", async () => { + // Narrow enough that even the collapsed icons do not fit -- scrolling is the + // last resort now, past dropping every label -- so whichever tab is active is + // the one the strip has to reach. + const panel = (activeTab: "sourceControl" | "agents") => ( +
+ +
+ +
+ ); + const mounted = await render(panel("agents")); + + try { + await expect.element(page.getByRole("tab", { name: "Source" })).toBeVisible(); + const viewport = document.querySelector( + '[data-right-panel-tabs-row="true"] [data-slot="scroll-area-viewport"]', + ) as HTMLElement; + const rightEdge = () => viewport.getBoundingClientRect().right + 0.5; + const leftEdge = () => viewport.getBoundingClientRect().left - 0.5; + const tabBox = (tab: string) => + document.querySelector(`[data-right-panel-tab='${tab}']`)!.getBoundingClientRect(); + + // Opening the panel on the last tab has already scrolled the row to it. + await vi.waitFor(() => { + if (viewport.scrollLeft <= 0 || tabBox("agents").right > rightEdge()) { + throw new Error("The strip never scrolled the active tab into view."); + } + }); + + // Selecting one off the other end brings it back, by the shortest scroll. + mounted.rerender(panel("sourceControl")); + await vi.waitFor(() => { + if (tabBox("sourceControl").left < leftEdge()) { + throw new Error("The strip never scrolled back to the newly active tab."); + } + }); + expect(viewport.scrollLeft).toBe(0); + } finally { + await mounted.unmount(); + } + }); + + it("leaves one open tab a tab, not a bar across the strip", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByRole("tab", { name: "Source" })).toBeVisible(); + const row = ( + document.querySelector("[data-right-panel-tabs-row='true']") as HTMLElement + ).getBoundingClientRect(); + const tab = ( + document.querySelector("[data-right-panel-tab='sourceControl']") as HTMLElement + ).getBoundingClientRect(); + const plus = ( + document.querySelector("[data-right-panel-add-tab='true']") as HTMLElement + ).getBoundingClientRect(); + + expect(tab.width).toBeLessThan(row.width / 2); + expect(plus.left - tab.right).toBeLessThan(8); + // The rest of the strip is empty rather than tab. + expect(row.right - plus.right).toBeGreaterThan(row.width / 3); + } finally { + await mounted.unmount(); + } + }); + + it("parks the + at the strip's left edge while the launcher is showing", async () => { + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByRole("button", { name: "Open panel" })).toBeVisible(); + const row = ( + document.querySelector("[data-right-panel-tabs-row='true']") as HTMLElement + ).getBoundingClientRect(); + const plus = ( + document.querySelector("[data-right-panel-add-tab='true']") as HTMLElement + ).getBoundingClientRect(); + + expect(document.querySelectorAll("[data-right-panel-tab]")).toHaveLength(0); + expect(plus.left - row.left).toBeLessThan(12); + } finally { + await mounted.unmount(); + } + }); + + it("shares the titlebar row with the window controls on Windows", async () => { + // The overlay's env() geometry does not exist in the test browser, so this + // covers the wiring the wco variant needs: one strip row, of titlebar + // height, padded clear of the min/max/close cluster, and draggable. + const mounted = await render( +
+ +
+ +
, + ); + + try { + const strip = await vi.waitFor(() => { + const found = document.querySelector("[data-right-panel-strip='true']"); + if (!found) throw new Error("The strip never rendered."); + return found as HTMLElement; + }); + const tabsRow = strip.querySelector("[data-right-panel-tabs-row='true']") as HTMLElement; + + // One row, and it is the strip's only one. + expect(strip.children).toHaveLength(1); + expect(strip.firstElementChild).toBe(tabsRow); + expect(strip.className).toContain("drag-region"); + // Titlebar height, and clear of the controls cluster. + expect(tabsRow.className).toContain("wco:min-h-[env(titlebar-area-height)]"); + expect(tabsRow.className).toContain("wco:pr-[calc(100vw-env(titlebar-area-width)"); + } finally { + await mounted.unmount(); + } + }); + + it("reveals a tab's ✕ on its right edge, costing the tab no width", async () => { + const onCloseTab = vi.fn(); + const mounted = await render( +
+ +
+ +
, + ); + + try { + await expect.element(page.getByRole("tab", { name: "Source" })).toBeVisible(); + const tab = document.querySelector("[data-right-panel-tab='sourceControl']") as HTMLElement; + const tabButton = tab.querySelector("[role='tab']") as HTMLElement; + const glyph = tab.querySelector("[data-right-panel-tab-glyph]") as HTMLElement; + const label = tab.querySelector("[data-right-panel-tab-label]") as HTMLElement; + const close = tab.querySelector("[data-right-panel-close-tab]") as HTMLElement; + const restingWidth = tab.getBoundingClientRect().width; + + // Nothing on show for a tab nobody is pointing at. + expect(getComputedStyle(close).opacity).toBe("0"); + + // Keyboard reaches it the same way the pointer does: focus the tab, then + // the ✕ is the next stop and shows itself on arrival. + tabButton.focus(); + await userEvent.keyboard("{Tab}"); + await vi.waitFor(() => { + if (document.activeElement !== close) { + throw new Error("Tabbing off the tab never reached its ✕."); + } + if (getComputedStyle(close).opacity !== "1") { + throw new Error("focus-visible never revealed the ✕."); + } + }); + + await page.getByRole("tab", { name: "Source" }).hover(); + await vi.waitFor(() => { + if (getComputedStyle(close).opacity !== "1") { + throw new Error("Hover never revealed the ✕."); + } + }); + + // The whole point of overlaying it: the tab is the same box revealed as it + // is at rest, so nothing in the strip's width is spent on it. + expect(tab.getBoundingClientRect().width).toBeCloseTo(restingWidth, 1); + + const closeBox = close.getBoundingClientRect(); + const tabBox = tab.getBoundingClientRect(); + // On the right edge, and the icon stays exactly where it was -- the ✕ used + // to take the glyph's slot, which moved the one thing naming the surface. + expect(closeBox.left).toBeGreaterThan(glyph.getBoundingClientRect().right); + expect(tabBox.right - closeBox.right).toBeLessThanOrEqual(4); + expect(getComputedStyle(glyph).opacity).toBe("1"); + // The label slides under it behind a short fade rather than being clipped + // to an ellipsis or pushed along. + expect(getComputedStyle(label).maskImage).toContain("gradient"); + expect(label.scrollWidth).toBeLessThanOrEqual(label.clientWidth + 1); + + await page.getByRole("button", { name: "Close Source" }).click(); + expect(onCloseTab).toHaveBeenCalledWith("sourceControl"); + } finally { + await mounted.unmount(); + } + }); + + it("closes an icon-only tab from the active tab's ✕, and any of them by middle-click", async () => { + // With no label to overlay there is nowhere for a per-tab ✕ to go that does + // not turn a 28px box into two opposite meanings, so an inactive icon offers + // none: hovering it goes to its surface. The active icon's ✕ takes the whole + // box -- a real target, not a badge on a corner -- and middle-click closes + // any of them outright, so no panel width strands a tab open. + const onCloseTab = vi.fn(); + const mounted = await render( +
+ +
+ +
, + ); + + try { + const strip = await vi.waitFor(() => { + const found = document.querySelector("[data-right-panel-strip='true']"); + if (found?.getAttribute("data-right-panel-strip-mode") !== "icons") { + throw new Error("The strip never collapsed to icons at 200px."); + } + return found as HTMLElement; + }); + + expect(strip.querySelector("[data-right-panel-close-tab='diff']")).toBeNull(); + expect(strip.querySelector("[data-right-panel-close-tab='agents']")).toBeNull(); + const activeTab = strip.querySelector( + "[data-right-panel-tab='sourceControl']", + ) as HTMLElement; + const close = activeTab.querySelector("[data-right-panel-close-tab]") as HTMLElement; + const closeGlyph = close.querySelector("[data-right-panel-close-glyph]") as HTMLElement; + const activeBox = activeTab.getBoundingClientRect(); + const closeBox = close.getBoundingClientRect(); + expect(closeBox.width).toBeGreaterThanOrEqual(24); + expect(closeBox.width).toBeCloseTo(activeBox.width, 0); + expect(closeBox.height).toBeCloseTo(activeBox.height, 0); + + const restingGlyphBackground = getComputedStyle(closeGlyph).backgroundColor; + await page.getByRole("button", { name: "Close Source" }).hover(); + await vi.waitFor(() => { + if (getComputedStyle(close).opacity !== "1") { + throw new Error("Hovering the active icon tab never revealed its ✕."); + } + if (getComputedStyle(closeGlyph).backgroundColor === restingGlyphBackground) { + throw new Error("The icon-only ✕ never gained its hover fill."); + } + }); + + await page.getByRole("button", { name: "Close Source" }).click(); + expect(onCloseTab).toHaveBeenCalledWith("sourceControl"); + + const diffTab = strip.querySelector("[data-right-panel-tab='diff']") as HTMLElement; + diffTab.dispatchEvent(new MouseEvent("auxclick", { bubbles: true, button: 1 })); + expect(onCloseTab).toHaveBeenCalledWith("diff"); + } 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..286cb057d --- /dev/null +++ b/apps/web/src/components/chat/AgentsPanel.tsx @@ -0,0 +1,489 @@ +import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import { ChevronRightIcon, XIcon } from "lucide-react"; +import { memo, useCallback, useMemo, type CSSProperties } from "react"; + +import type { SubagentProgressItem, ThreadSubagentHistoryEntry } from "../../session-logic"; +import { cn } from "~/lib/utils"; +import { selectAgentsPanelAgent, useSelectedAgentId } from "../../agentsPanelStore"; +import type { Icon } from "../Icons"; +import { Button } from "../ui/button"; +import { LiveNode, SectionLabel } from "../ui/threadline"; +import { providerIconForDriverLabel } from "./providerIconUtils"; +import { SubagentInspector } from "./SubagentInspector"; +import { deriveSubagentDisplayDetails, type ThreadBackgroundRunItem } from "./threadActivity"; +import { + buildAgentsPanelView, + findAgentsPanelSubagent, + formatAgentsHeaderMeta, + formatAgentsPanelSummary, + hasRunningAgentActivity, + type AgentBranch, + type AgentBranchStatus, +} from "./agentsPanel.logic"; + +export interface AgentsPanelProps { + environmentId: EnvironmentId; + threadId: ThreadId; + subagents: ReadonlyArray; + backgroundRuns: ReadonlyArray; + /** Every agent the thread has run, from its durable activity projection. Keeps + * the panel populated (and receipts resolvable) after the turn ends. */ + history?: ReadonlyArray | undefined; + /** Drives the trunk hue, the branch glyphs and the provenance chip, e.g. `codex`. */ + providerLabel?: string | null | undefined; + /** Whether a turn is running right now. Only changes the empty state: a turn + * that has not reached the provider yet may still spawn agents, so the panel + * says it is waiting instead of saying the thread has never run one. */ + turnInFlight?: boolean; + /** Working directory, used to resolve file references in agent prose. */ + threadCwd?: string | null | undefined; + /** Set when the panel renders inside the sidebar's tab strip, which already + * carries the window chrome, the panel's name and its dismissal. */ + embedded?: boolean; + 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 ( +
- {activeProjectScripts && ( Toggle browser preview ) : null} - {sourceControlAvailable || sourceControlOpen ? ( - - - - {/* Only while closed: once the panel is open it shows the - per-file counts, and repeating the total is noise. */} - {!sourceControlOpen && (workingTreeDiffStat || remoteBehindCount !== null) ? ( - - {workingTreeDiffStat ? ( - <> - - +{workingTreeDiffStat.insertions} - - - −{workingTreeDiffStat.deletions} - - - ) : null} - {/* Deliberately hue-less: the arrow is the signal, and a - third color next to the green/red counts would crowd - an icon-sized control. */} - {remoteBehindCount !== null ? ( - - ↓{remoteBehindCount} + {/* One entry point for the whole rail: the tab row inside it picks + between the turn's agents and the thread's changes. */} + + + + {/* Only while closed: once the rail is open its Source tab + shows the per-file counts, and repeating the total is + noise. */} + {!railOpen && (workingTreeDiffStat || remoteBehindCount !== null) ? ( + + {workingTreeDiffStat ? ( + <> + +{workingTreeDiffStat.insertions} + + −{workingTreeDiffStat.deletions} - ) : null} - - ) : null} - - } - /> - - {!sourceControlAvailable && !sourceControlOpen - ? "Source control is unavailable until this thread has an active project." - : sourceControlToggleShortcutLabel - ? `Toggle source control panel (${sourceControlToggleShortcutLabel})` - : "Toggle source control panel"} - {!sourceControlOpen && sourceControlAvailable && remoteBehindCount !== null ? ( -
- {remoteBehindCount === 1 - ? "1 commit behind the remote." - : `${remoteBehindCount} commits behind the remote.`}{" "} - Pull from the source control panel. -
- ) : null} -
-
- ) : null} + + ) : null} + {/* Deliberately hue-less: the arrow is the signal, and a + third color next to the green/red counts would crowd + an icon-sized control. */} + {remoteBehindCount !== null ? ( + + ↓{remoteBehindCount} + + ) : null} +
+ ) : null} + {/* Typographic, like the counts beside it: a node and at most + a digit. An agent waiting on the user turns it amber. */} + {!railOpen && liveAgents ? ( + 0 ? "waiting" : "running" + } + > + {liveAgents.waitingCount > 0 ? ( + + ) : null} + + } + /> + + {railToggleShortcutLabel ? `Panel (${railToggleShortcutLabel})` : "Panel"} + {!railOpen && liveAgents ? ( +
+ {formatLiveAgentsTooltip(liveAgents)} Open the Agents tab. +
+ ) : null} + {!railOpen && sourceControlAvailable && remoteBehindCount !== null ? ( +
+ {remoteBehindCount === 1 + ? "1 commit behind the remote." + : `${remoteBehindCount} commits behind the remote.`}{" "} + Pull from the Source tab. +
+ ) : null} +
+
diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index 9561a043f..b77786840 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -225,6 +225,47 @@ function buildSubagentResultTimelineEntry(objective: string) { }; } +const ACTIVITY_ROW_TURN_ID = TurnId.make("turn-activity"); + +/** Enough tool rows that the work row collapses into its activity receipt, + * which is the row the subagent summary extends. */ +function buildOverflowingWorkTimelineEntries() { + return Array.from({ length: 8 }, (_, index) => ({ + id: `work-${index}`, + kind: "work" as const, + createdAt: `2026-04-13T12:0${index}:00.000Z`, + entry: { + id: `work-${index}`, + createdAt: `2026-04-13T12:0${index}:00.000Z`, + turnId: ACTIVITY_ROW_TURN_ID, + label: "command", + detail: `Step ${index + 1}`, + command: `echo step-${index + 1}`, + tone: "tool" as const, + }, + })); +} + +function buildTurnSubagent(id: string, status: "running" | "completed" | "waiting") { + return { + id, + agentThreadId: id, + transcriptAgentId: id, + turnId: ACTIVITY_ROW_TURN_ID, + label: "Subagent", + role: null, + objective: "Review the change", + status, + statusLabel: status === "waiting" ? "Needs approval" : "Running", + model: null, + reasoningEffort: null, + liveBody: null, + telemetry: null, + createdAt: "2026-04-13T12:00:00.000Z", + updatedAt: "2026-04-13T12:00:10.000Z", + }; +} + describe("MessagesTimeline", () => { afterEach(() => { scrollToEndSpy.mockReset(); @@ -986,59 +1027,6 @@ describe("MessagesTimeline", () => { } }); - it("expands truncated subagent result instructions when clicking the text", async () => { - const longObjective = [ - "This is a UI preview task only.", - "Do not edit files or run destructive commands.", - "Please return a concise chat-style response with markdown formatting.", - "Include one heading, one bullet list, one inline-code example, and one file reference.", - "Keep the final instruction visible only after the clamped text expands.", - ].join(" "); - const screen = await renderTimeline( -
- -
, - ); - - try { - await vi.waitFor(() => { - const objective = document.querySelector( - "[data-subagent-result-objective='true']", - ); - expect(objective).not.toBeNull(); - expect(objective?.tagName).toBe("BUTTON"); - expect(objective?.getAttribute("data-subagent-result-objective-truncated")).toBe("true"); - expect(objective?.getAttribute("aria-expanded")).toBe("false"); - expect(objective?.className).toContain("line-clamp-2"); - }); - - await page.getByRole("button", { name: "Expand subagent instructions" }).click(); - - await vi.waitFor(() => { - const objective = document.querySelector( - "[data-subagent-result-objective='true']", - ); - expect(objective?.getAttribute("data-subagent-result-objective-expanded")).toBe("true"); - expect(objective?.className).not.toContain("line-clamp-2"); - }); - - await page.getByRole("button", { name: "Collapse subagent instructions" }).click(); - - await vi.waitFor(() => { - const objective = document.querySelector( - "[data-subagent-result-objective='true']", - ); - expect(objective?.getAttribute("data-subagent-result-objective-expanded")).toBe("false"); - expect(objective?.className).toContain("line-clamp-2"); - }); - } finally { - await screen.unmount(); - } - }); - it("expands assistant changed-files trees from the header when the default is collapsed", async () => { const turnId = TurnId.make("turn-1"); const assistantMessageId = MessageId.make("assistant-1"); @@ -1114,4 +1102,290 @@ describe("MessagesTimeline", () => { await screen.unmount(); } }); + + it("summarizes the turn's subagents on the activity row and opens the panel from it", async () => { + const onOpenAgentsPanel = vi.fn(); + const screen = await renderTimeline( + , + ); + + try { + const summary = page.getByRole("button", { + name: "4 subagents · 1 done · 1 needs you. Open the agents panel.", + }); + await expect.element(summary).toBeVisible(); + + await summary.click(); + expect(onOpenAgentsPanel).toHaveBeenCalledWith(null); + } finally { + await screen.unmount(); + } + }); + + it("keeps the tracker row on a reloaded turn that only delegated, with no count and nothing to expand", async () => { + const onOpenAgentsPanel = vi.fn(); + // Every entry in the turn is agent lifecycle plumbing, so the conversation + // has nothing of the main model's to narrate. The tracker still has to be + // here: it is the only inline sign that two agents ran. + // + // This is the cold-load shape, which is how the row is seen most of the + // time: the turn settled before the page was opened, so there is no live + // agent state at all and the tracker has to come off the durable history. + const screen = await renderTimeline( + ({ + id: `entry-collab-${tool}`, + kind: "work" as const, + createdAt: `2026-04-13T12:00:0${index}.000Z`, + entry: { + id: `work-collab-${tool}`, + createdAt: `2026-04-13T12:00:0${index}.000Z`, + completedAt: `2026-04-13T12:00:1${index}.000Z`, + label: "Subagent task", + detail: tool, + tone: "tool" as const, + itemType: "collab_agent_tool_call" as const, + executionState: "completed" as const, + turnId: ACTIVITY_ROW_TURN_ID, + }, + }))} + onOpenAgentsPanel={onOpenAgentsPanel} + turnAgents={{ + subagents: [], + history: [ + { item: buildTurnSubagent("agent-1", "completed"), resultBody: "50 .tsx files." }, + { item: buildTurnSubagent("agent-2", "completed"), resultBody: "31 .ts files." }, + // Another turn's agent is in the same history and must not count here. + { + item: { ...buildTurnSubagent("agent-3", "completed"), turnId: TurnId.make("other") }, + resultBody: null, + }, + ], + }} + />, + ); + + try { + const summary = page.getByRole("button", { + name: "2 subagents · 2 done. Open the agents panel.", + }); + await expect.element(summary).toBeVisible(); + await summary.click(); + expect(onOpenAgentsPanel).toHaveBeenCalledWith(null); + + const receipt = document.querySelector("[data-work-activity-receipt='true']"); + expect(receipt).not.toBeNull(); + expect(receipt?.getAttribute("data-work-activity-anchor")).toBe("true"); + // No misleading count, and no lifecycle row anywhere in the chat. + expect(receipt?.textContent).not.toContain("actions"); + expect(document.body.textContent).not.toContain("Subagent task"); + // Nothing was hidden, so there is nothing to unhide. + expect(document.querySelector("[data-activity-transcript-toggle='true']")).toBeNull(); + } finally { + await screen.unmount(); + } + }); + + it("gives a turn's tracker to its first activity group only", async () => { + // A subagent's report splits the turn's work into two activity groups. The + // tracker describes the whole turn, so repeating it on the second group + // would read as a duplicated row rather than as more information. + const workEntry = (id: string, createdAt: string) => ({ + id, + kind: "work" as const, + createdAt, + entry: { + id, + createdAt, + turnId: ACTIVITY_ROW_TURN_ID, + label: "command", + detail: id, + command: `echo ${id}`, + tone: "tool" as const, + }, + }); + const screen = await renderTimeline( + + workEntry(`first-${index}`, `2026-04-13T12:0${index}:00.000Z`), + ), + { + ...buildSubagentResultTimelineEntry("Count the files"), + result: { + ...buildSubagentResultTimelineEntry("Count the files").result, + turnId: ACTIVITY_ROW_TURN_ID, + }, + }, + ...Array.from({ length: 8 }, (_, index) => + workEntry(`second-${index}`, `2026-04-13T12:1${index}:00.000Z`), + ), + ]} + onOpenAgentsPanel={vi.fn()} + turnAgents={{ + subagents: [], + history: [ + { item: buildTurnSubagent("agent-1", "completed"), resultBody: "50 .tsx files." }, + { item: buildTurnSubagent("agent-2", "completed"), resultBody: "40 .ts files." }, + ], + }} + />, + ); + + try { + const receipts = [...document.querySelectorAll("[data-work-activity-receipt='true']")]; + expect(receipts.length).toBe(2); + + const trackers = [...document.querySelectorAll("[data-turn-agents-summary='true']")]; + expect(trackers.length).toBe(1); + // On the group the turn started in, not a later one. + expect(receipts[0]?.contains(trackers[0] ?? null)).toBe(true); + expect(trackers[0]?.getAttribute("aria-label")).toBe( + "2 subagents · 2 done. Open the agents panel.", + ); + } finally { + await screen.unmount(); + } + }); + + it("shows one live agent status line under the tracker row and drops it when nothing is live", async () => { + const onOpenAgentsPanel = vi.fn(); + const liveSubagent = (id: string, step: string, updatedAt: string) => ({ + ...buildTurnSubagent(id, "running"), + nickname: id === "agent-fresh" ? "Agent panel tests" : "Router sweep", + telemetry: { + step, + lastToolName: null, + totalTokens: null, + toolUses: null, + durationMs: null, + }, + updatedAt, + }); + + const screen = await renderTimeline( + , + ); + + try { + // Two agents are live, but the conversation gets exactly one line: the + // freshest signal, named. + const statusLines = document.querySelectorAll("[data-turn-live-agent-status='true']"); + expect(statusLines.length).toBe(1); + expect(statusLines[0]?.textContent).toContain("Agent panel tests"); + expect(statusLines[0]?.textContent).toContain("reading AgentsPanel.browser.tsx"); + + (statusLines[0] as HTMLElement).click(); + expect(onOpenAgentsPanel).toHaveBeenCalledWith(null); + } finally { + await screen.unmount(); + } + + const settled = await renderTimeline( + , + ); + + try { + expect(document.querySelector("[data-turn-live-agent-status='true']")).toBeNull(); + } finally { + await settled.unmount(); + } + }); + + it("renders a finished subagent as a one-line receipt that drills into it", async () => { + const onOpenAgentsPanel = vi.fn(); + const screen = await renderTimeline( + , + ); + + try { + const receipt = page.getByRole("button", { name: "Open Reviewer transcript" }); + await expect.element(receipt).toBeVisible(); + // A receipt, not a card: the full report stays in the rail. + expect(document.querySelector("[data-subagent-result-body='true']")).toBeNull(); + expect(document.querySelector("[data-subagent-receipt-row='true']")?.textContent).toContain( + "Finding: subagent output is visible.", + ); + + await receipt.click(); + expect(onOpenAgentsPanel).toHaveBeenCalledWith("agent-1"); + } finally { + await screen.unmount(); + } + }); + + it("keeps a running subagent's commentary out of the conversation", async () => { + const screen = await renderTimeline( + , + ); + + try { + await expect + .element(page.getByText("Halfway through the router sweep.")) + .not.toBeInTheDocument(); + expect(document.querySelector("[data-subagent-live-row='true']")).toBeNull(); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 198298373..28ddae5ba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -277,6 +277,45 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("keeps a subagent's own tool calls out of the conversation's work rows", () => { + const workEntry = (id: string, overrides: Partial = {}) => ({ + id, + kind: "work" as const, + createdAt: "2026-01-01T00:00:00Z", + entry: { + id, + createdAt: "2026-01-01T00:00:00Z", + label: id, + tone: "tool" as const, + turnId: "turn-1" as never, + ...overrides, + }, + }); + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + workEntry("main-read"), + workEntry("child-read", { sourceAgentThreadId: "agent-1" }), + workEntry("child-edit", { sourceAgentThreadId: "agent-1" }), + workEntry("main-edit"), + ], + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + // One group of the main agent's two rows: the receipt's "2 actions" count + // comes straight off this list, so excluding here excludes from the count. + expect(rows.length).toBe(1); + const [row] = rows; + expect(row?.kind === "work" ? row.groupedEntries.map((entry) => entry.id) : null).toEqual([ + "main-read", + "main-edit", + ]); + }); + it("uses the active status label for the live activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f8c16d087..9face3fd0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,7 +2,6 @@ import * as Equal from "effect/Equal"; import { type ModelFallbackState, type ForkContextEntry, - type SubagentLiveEntry, type SubagentResultEntry, type TimelineEntry, type WorkLogEntry, @@ -59,6 +58,15 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; + /** Agent lifecycle entries this group swallowed. Never rendered and never + * counted, but kept so the group still exists on a turn that did nothing + * but delegate: the turn's agent tracker and its duration hang off it. */ + agentAnchorEntries: WorkLogEntry[]; + /** The turns this group shows the agent tracker for. A tracker summarizes + * a whole turn, so only the turn's first group carries it; a later group + * in the same turn would repeat the same bars and count. Empty means this + * group shows no tracker at all. */ + trackerTurnIds: TurnId[]; isLive: boolean; liveStartedAt: string | null; } @@ -89,12 +97,6 @@ export type MessagesTimelineRow = createdAt: string; result: SubagentResultEntry; } - | { - kind: "subagent-live"; - id: string; - createdAt: string; - live: SubagentLiveEntry; - } | { kind: "fork-context"; id: string; @@ -279,6 +281,8 @@ export function deriveMessagesTimelineRows(input: { const modelFallbackByTurn = deriveModelFallbackByTurn(visibleTimelineEntries); const supersededRunningCommandEntryIds = inferSupersededRunningCommandEntryIds(visibleTimelineEntries); + /** Turns whose tracker has already been handed to an earlier group. */ + const trackedTurnIds = new Set(); for (let index = 0; index < visibleTimelineEntries.length; index += 1) { const timelineEntry = visibleTimelineEntries[index]; @@ -287,23 +291,41 @@ export function deriveMessagesTimelineRows(input: { } if (timelineEntry.kind === "work") { - const groupedEntries = [ - settleSupersededRunningCommandEntry(timelineEntry.entry, supersededRunningCommandEntryIds), - ]; + const groupedEntries: WorkLogEntry[] = []; + const agentAnchorEntries: WorkLogEntry[] = []; + const collect = (entry: Extract) => { + const settled = settleSupersededRunningCommandEntry( + entry.entry, + supersededRunningCommandEntryIds, + ); + (isAgentLifecycleEntry(entry) ? agentAnchorEntries : groupedEntries).push(settled); + }; + collect(timelineEntry); let cursor = index + 1; while (cursor < visibleTimelineEntries.length) { const nextEntry = visibleTimelineEntries[cursor]; if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push( - settleSupersededRunningCommandEntry(nextEntry.entry, supersededRunningCommandEntryIds), - ); + collect(nextEntry); cursor += 1; } + // First group of a turn wins its tracker: that is where the turn's story + // starts, and while the turn runs it is where the live status line belongs. + const trackerTurnIds: TurnId[] = []; + for (const entry of [...groupedEntries, ...agentAnchorEntries]) { + const turnId = entry.turnId; + if (turnId === null || turnId === undefined || trackedTurnIds.has(turnId)) { + continue; + } + trackedTurnIds.add(turnId); + trackerTurnIds.push(turnId); + } nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries, + agentAnchorEntries, + trackerTurnIds, isLive: false, liveStartedAt: null, }); @@ -331,13 +353,10 @@ export function deriveMessagesTimelineRows(input: { continue; } + // A running agent's streamed commentary never reaches the conversation: + // the turn's activity row summarizes what is running, and the rail carries + // the detail. Only the finished agent's receipt lands here. if (timelineEntry.kind === "subagent-live") { - nextRows.push({ - kind: "subagent-live", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - live: timelineEntry.live, - }); continue; } @@ -420,18 +439,64 @@ export function deriveMessagesTimelineRows(input: { return nextRows; } +/** + * A spawned agent's own tool calls are not the conversation's activity, so they + * never reach the chat: not as expanded rows, and not in the receipt's counts. + * The turn's tracker row says how many agents ran and the rail's Agents tab owns + * what each of them did. The entries themselves are untouched — every other + * reader of the work log still sees them. + */ +function isSubagentAttributedEntry(entry: TimelineEntry): boolean { + return entry.kind === "work" && entry.entry.sourceAgentThreadId !== undefined; +} + +/** + * Agent lifecycle plumbing: the spawn/poll/close tool calls the main model makes + * to run an agent, and the provider's own task stream for one. None of it is + * work the conversation should narrate — the turn's tracker row says how many + * agents ran, each finished agent files exactly one receipt, and the Agents tab + * owns the detail. So the entries never render and never count, but they are not + * discarded either: they stay on the row as its anchor, because a turn that only + * delegated still has to carry that tracker. + * + * Both signals are payload-level, not label text: `collab_agent_tool_call` is + * the item type every provider's agent tool call projects under (Codex's + * `spawnAgent`/`wait`/`sendInput`/`closeAgent`, Claude's `Agent`/`Task`), and a + * task activity is only an agent's when it carries that agent's identity — + * Claude's background bash tasks share the activity kinds and stay. + */ +function isAgentLifecycleEntry(entry: TimelineEntry): boolean { + if (entry.kind !== "work") { + return false; + } + const { itemType, activityKind, subagentTask } = entry.entry; + if (itemType === "collab_agent_tool_call") { + return true; + } + return ( + (activityKind === "task.progress" || activityKind === "task.completed") && + subagentTask !== undefined + ); +} + function deriveVisibleTimelineEntries(input: { readonly timelineEntries: ReadonlyArray; readonly isWorking: boolean; readonly activeTurnId?: TurnId | null; }): TimelineEntry[] { - const visibleByIndex = Array.from({ length: input.timelineEntries.length }, () => true); + // Agent lifecycle entries stay in this pass: they still count as concrete turn + // activity for the provider-lifecycle row's own visibility, and the grouping + // step below is what parks them out of sight. + const timelineEntries = input.timelineEntries.filter( + (entry) => !isSubagentAttributedEntry(entry), + ); + const visibleByIndex = Array.from({ length: timelineEntries.length }, () => true); let hasLaterProviderLifecycle = false; let hasLaterConcreteTurnActivity = false; const laterConcreteTurnIds = new Set(); - for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { - const timelineEntry = input.timelineEntries[index]; + for (let index = timelineEntries.length - 1; index >= 0; index -= 1) { + const timelineEntry = timelineEntries[index]; if (!timelineEntry) { continue; } @@ -457,7 +522,7 @@ function deriveVisibleTimelineEntries(input: { } } - return input.timelineEntries.filter((_, index) => visibleByIndex[index]); + return timelineEntries.filter((_, index) => visibleByIndex[index]); } function shouldShowProviderLifecycleWorkEntry( @@ -529,6 +594,12 @@ function markLatestLiveWorkRow( if (!lastRow || lastRow.kind !== "work") { return false; } + // An anchor-only group has no step to hang the live node on — it renders as the + // turn's agent tracker, not as a spine — so the standalone working row still + // has to carry the live node at the bottom. + if (lastRow.groupedEntries.length === 0) { + return false; + } // Reasoning and other lifecycle entries arrive without a turn id, so a tail // work group that carries no turn association is still treated as the live // one rather than handed off to a detached working row. @@ -586,18 +657,20 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "subagent-result": return a.result === (b as typeof a).result; - case "subagent-live": - return a.live === (b as typeof a).live; - case "fork-context": return a.forkContext === (b as typeof a).forkContext; - case "work": + case "work": { + const bw = b as typeof a; return ( - a.isLive === (b as typeof a).isLive && - a.liveStartedAt === (b as typeof a).liveStartedAt && - Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries) + a.isLive === bw.isLive && + a.liveStartedAt === bw.liveStartedAt && + a.trackerTurnIds.length === bw.trackerTurnIds.length && + a.trackerTurnIds.every((turnId, index) => turnId === bw.trackerTurnIds[index]) && + Equal.equals(a.groupedEntries, bw.groupedEntries) && + Equal.equals(a.agentAnchorEntries, bw.agentAnchorEntries) ); + } case "message": { const bm = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f8b259b39..b9ad6cf27 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -873,113 +873,90 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("MessagesTimeline.test.tsx"); }); - it("renders subagent tool calls with delegated-work language", async () => { + it("keeps agent lifecycle rows out of the conversation and out of its counts", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderTimeline( - , - ); - - expect(markup).toContain("Spawned subagent"); - expect(markup).not.toContain("Finished reviewer subagent"); - expect(markup).toContain("Inspect timeline rendering"); - expect(markup).toContain('data-subagent-activity-row="true"'); - expect(markup).toContain("Subagent"); - expect(markup).toContain("Details"); - expect(markup).not.toContain('data-subagent-activity-details="true"'); - }); + // Codex: the spawn itself, then the polls that wait on it. Neither carries a + // source agent thread id, which is why filtering on attribution alone left + // them in the chat. + const lifecycleEntries = ( + [ + ["spawn", "reviewer: Inspect timeline rendering", "delegation"], + ["wait", "wait", "coordination"], + ["send-input", "sendInput", "coordination"], + ] as const + ).map(([id, detail, operation], index) => ({ + id: `entry-${id}`, + kind: "work" as const, + createdAt: `2026-03-17T19:12:2${index}.000Z`, + entry: { + id: `work-${id}`, + createdAt: `2026-03-17T19:12:2${index}.000Z`, + label: "Subagent task", + detail, + tone: "tool" as const, + itemType: "collab_agent_tool_call" as const, + subagentOperation: operation, + executionState: "completed" as const, + }, + })); + // Claude reports the same lifecycle through its own task stream instead. + const taskStreamEntry = { + id: "entry-task-progress", + kind: "work" as const, + createdAt: "2026-03-17T19:12:23.000Z", + entry: { + id: "work-task-progress", + createdAt: "2026-03-17T19:12:23.000Z", + label: "Subagent task", + detail: "Reading the timeline", + tone: "thinking" as const, + activityKind: "task.progress" as const, + subagentTask: { subagentType: "code-reviewer", toolUseId: "toolu_spawn_1" }, + executionState: "completed" as const, + }, + }; + // The main model's own tool calls stay, and the receipt counts only them. + const mainAgentEntries = ["context7", "playwright", "figma"].map((tool, index) => ({ + id: `entry-tool-${tool}`, + kind: "work" as const, + createdAt: `2026-03-17T19:12:3${index}.000Z`, + entry: { + id: `work-tool-${tool}`, + createdAt: `2026-03-17T19:12:3${index}.000Z`, + label: `Used ${tool}`, + tone: "tool" as const, + itemType: "mcp_tool_call" as const, + executionState: "completed" as const, + }, + })); - it("does not count subagent coordination polls as delegated tasks", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderTimeline( ({ - id: `entry-subagent-${operation}`, - kind: "work" as const, - createdAt: `2026-07-13T18:38:4${index}.000Z`, - entry: { - id: `work-subagent-${operation}`, - createdAt: `2026-07-13T18:38:4${index}.000Z`, - label: "Subagent task", - detail: operation, - tone: "tool" as const, - itemType: "collab_agent_tool_call" as const, - subagentOperation: "coordination" as const, - executionState: "completed" as const, - }, - }))} + timelineEntries={[...lifecycleEntries, taskStreamEntry, ...mainAgentEntries]} />, ); - expect(markup).toContain("Used 2 tools"); + // Nothing about running an agent narrates itself in the chat any more. + expect(markup).not.toContain("Subagent task"); + expect(markup).not.toContain("Spawned subagent"); + expect(markup).not.toContain("Finished subagent task"); expect(markup).not.toContain("Delegated work"); - expect(markup).not.toContain("2 subagent tasks"); + expect(markup).not.toContain("subagent tasks"); + expect(markup).not.toContain("Inspect timeline rendering"); + expect(markup).not.toContain("Reading the timeline"); + // The summary line and the action count are recomputed from what is left. + expect(markup).toContain("Used 3 tools"); + expect(markup).toContain("3 actions"); + expect(markup).not.toContain("7 actions"); }); - it("renders final subagent results as distinct timeline rows", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderTimeline( - , - ); - - expect(markup).toContain('data-subagent-result-row="true"'); - expect(markup).toContain('data-subagent-result-body="true"'); - expect(markup).toContain('data-subagent-result-collapsible="false"'); - expect(markup).toContain("Heisenberg"); - expect(markup).toContain("Reviewer subagent"); - expect(markup).toContain("Inspect timeline rendering"); - expect(markup).toContain("subagent output is visible"); - expect(markup).toContain("Subagent"); - expect(markup).toContain('data-subagent-result-meta-chip="true"'); - expect(markup).toContain("gpt-5.5"); - expect(markup).toContain("medium"); - }); - - it("renders live subagent commentary as a flat transient row", async () => { + it("renders a finished subagent as a compact receipt and drops live commentary", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderTimeline( { reasoningEffort: "medium", }, }, - ]} - />, - ); - - expect(markup).toContain('data-subagent-live-row="true"'); - expect(markup).toContain('data-subagent-live-body="true"'); - expect(markup).toContain("Heisenberg"); - expect(markup).toContain("Subagent"); - expect(markup).toContain("Working"); - expect(markup).toContain("Live commentary"); - expect(markup).toContain("tracing the Codex child events now"); - expect(markup).not.toContain('data-subagent-result-row="true"'); - }); - - it("labels a completed spawn operation as spawned rather than finished", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderTimeline( - , - ); - - expect(markup).toContain("Spawned subagent"); - expect(markup).not.toContain("Finished subagent task"); - }); - - it("collapses very long subagent results and keeps meta chips in the footer", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const longBody = Array.from({ length: 40 }, (_, index) => `- finding ${index}`).join("\n"); - const markup = renderTimeline( - , ); - expect(markup).toContain('data-subagent-result-collapsible="true"'); - expect(markup).toContain('data-subagent-result-collapsed="true"'); - expect(markup).toContain("Show full result"); - expect(markup).toContain('data-subagent-result-meta-chip="true"'); - expect(markup).toContain("claude-fable-5"); - expect(markup).toContain("high"); + expect(markup).toContain('data-subagent-receipt-row="true"'); + expect(markup).toContain('data-subagent-receipt-open="true"'); + expect(markup).toContain("Heisenberg"); + expect(markup).toContain("Findings"); + expect(markup).toContain("Subagent"); + expect(markup).toContain("gpt-5.5"); + // The report itself stays in the rail: no card, no inlined body. + expect(markup).not.toContain('data-subagent-result-body="true"'); + expect(markup).not.toContain("subagent output is visible"); + // Nothing renders for a still-running agent. + expect(markup).not.toContain('data-subagent-live-row="true"'); + expect(markup).not.toContain("tracing the Codex child events now"); }); it("marks agent response bodies without changing markdown rendering", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6b26e083f..36b512459 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -31,10 +31,19 @@ import { deriveTimelineEntries, formatElapsed, formatSubagentDisplayName, - shouldShowSubagentDisplayChip, type McpAuthReconnectAction, type ProviderAuthReconnectAction, + type SubagentProgressItem, + type ThreadSubagentHistoryEntry, } from "../../session-logic"; +import { + formatLiveAgentStatusLine, + formatSubagentReceiptSummary, + selectTurnAgents, + summarizeTurnAgents, + type LiveAgentStatusLine, + type TurnAgentSummary, +} from "./agentsPanel.logic"; import { DEFAULT_SCROLL_END_TOLERANCE_PX, isScrollMetricsAtEnd } from "../ChatView.logic"; import { type ChatAttachment, type TurnDiffSummary } from "../../types"; import { chatAttachmentPreviewQueryOptions } from "../../lib/attachmentPreviewQuery"; @@ -165,6 +174,17 @@ interface TimelineRowSharedState { searchTargetQuery: string; activeSearchTargetMessageId: MessageId | null; proposedPlanState: TimelineProposedPlanState | null; + turnAgents: TimelineTurnAgentsState | null; + onOpenAgentsPanel: ((agentThreadId: string | null) => void) | null; +} + +/** The turn's spawned agents, summarized on the turn's activity row. */ +export interface TimelineTurnAgentsState { + /** Live state for the turn in flight; empty for every settled turn. */ + readonly subagents: ReadonlyArray; + /** The thread's durable agent history — the same records the agents panel + * lists — so a settled turn's tracker survives the turn ending and a reload. */ + readonly history?: ReadonlyArray | undefined; } /** Lifecycle context for proposed-plan rows: which plan is still actionable, @@ -585,6 +605,13 @@ interface MessagesTimelineProps { | null | undefined; proposedPlanState?: TimelineProposedPlanState | null | undefined; + turnAgents?: TimelineTurnAgentsState | null | undefined; + /** + * Opens the rail's Agents tab, optionally drilled into one agent. Passed in + * separately from `turnAgents` on purpose: a finished agent's receipt has to + * stay clickable after the live progress state for the turn has emptied. + */ + onOpenAgentsPanel?: ((agentThreadId: string | null) => void) | null | undefined; } // --------------------------------------------------------------------------- @@ -630,6 +657,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ searchTarget = null, planScrollTarget = null, proposedPlanState = null, + turnAgents = null, + onOpenAgentsPanel = null, }: MessagesTimelineProps) { const rawRows = useMemo( () => @@ -1225,6 +1254,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ searchTargetQuery: searchTarget?.query ?? "", activeSearchTargetMessageId, proposedPlanState, + turnAgents, + onOpenAgentsPanel, }), [ timestampFormat, @@ -1252,6 +1283,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ searchTarget?.query, activeSearchTargetMessageId, proposedPlanState, + turnAgents, + onOpenAgentsPanel, ], ); const activityState = useMemo( @@ -1724,8 +1757,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "fork-context" ? : null} {row.kind === "proposed-plan" ? : null} - {row.kind === "subagent-live" ? : null} - {row.kind === "subagent-result" ? : null} + {row.kind === "subagent-result" ? : null} {row.kind === "working" ? : null}
); @@ -2337,260 +2369,78 @@ const COLLAPSED_MESSAGE_FADE_STYLE: CSSProperties = { maskImage: COLLAPSED_MESSAGE_FADE_MASK, }; -const MAX_COLLAPSED_SUBAGENT_RESULT_LINES = 12; -const MAX_COLLAPSED_SUBAGENT_RESULT_LENGTH = 900; - -function SubagentLiveTimelineRow({ - row, -}: { - row: Extract; -}) { - const ctx = use(TimelineRowCtx); - const displayName = formatSubagentDisplayName(row.live); - - return ( -
-
-
-
-
- -
-

- Live commentary · {formatTimestamp(row.createdAt, ctx.timestampFormat)} -

-
-
- ); -} - -function SubagentResultTimelineRow({ +/** + * A finished agent, as one line of the conversation. The full report is not + * inlined: the rail's Agents tab owns the transcript, and this row is the + * receipt that says it happened and takes you there. + */ +function SubagentReceiptTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); - const [expanded, setExpanded] = useState(false); - const metaChips = [row.result.model, row.result.reasoningEffort].filter( - (part): part is string => typeof part === "string" && part.length > 0, - ); const displayName = formatSubagentDisplayName(row.result); - const showSubagentChip = shouldShowSubagentDisplayChip(row.result); - const canCollapse = shouldCollapseMessageText(row.result.body, { - maxLength: MAX_COLLAPSED_SUBAGENT_RESULT_LENGTH, - maxLines: MAX_COLLAPSED_SUBAGENT_RESULT_LINES, - }); - const isCollapsed = canCollapse && !expanded; + const summary = formatSubagentReceiptSummary(row.result.body); + const agentThreadId = row.result.agentThreadId; + const meta = [row.result.model, formatTimestamp(row.createdAt, ctx.timestampFormat)] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join(" · "); + // A provider that serves no transcript for this agent has nothing to drill + // into, so the receipt is just a line of the record. + const onOpenAgentsPanel = ctx.onOpenAgentsPanel; + const interactive = onOpenAgentsPanel !== null && agentThreadId.length > 0; + + const body = ( + <> +