diff --git a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx index 8d205ee0b2e..c778abb2ab2 100644 --- a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx +++ b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx @@ -59,7 +59,7 @@ describe("TaskChatLiveTail", () => { expect(container.textContent).toContain("src/app.ts"); }); - it("renders a tool's diff inset", () => { + it("keeps tool diff bodies out of the activity feed", () => { const items = parse([ { kind: "tool_call", ts: TS, name: "Edit", toolUseId: "t1", input: { file_path: "a.ts" } }, { kind: "diff", ts: TS, changeType: "add", text: "const x = 1;" }, @@ -69,8 +69,16 @@ describe("TaskChatLiveTail", () => { const phaseSummary = container.querySelector('[data-testid="task-chat-phase-summary"]'); expect(phaseSummary?.getAttribute("aria-expanded")).toBe("true"); - expect(container.textContent).toContain("const x = 1;"); + expect(container.textContent).not.toContain("const x = 1;"); + expect(container.textContent).not.toContain("+1 −1"); + + const tool = container.querySelector(".tc-enter-tool button"); + expect(tool).not.toBeNull(); + flushSync(() => tool?.click()); + expect(container.textContent).toContain("+1 −1"); + expect(container.textContent).not.toContain("const x = 1;"); + expect(container.querySelector('[data-testid="task-chat-tool-change-summary"]')).not.toBeNull(); }); it("drops the debug plumbing kinds RunTranscriptView surfaced", () => { diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx index 0c2e7a0ee8b..766c2e0db8d 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.test.tsx @@ -33,7 +33,7 @@ describe("TaskChatProtocolActivityRow", () => { )); } - it("renders research as one compact line with expandable sources", () => { + it("renders long research results as a bounded readable list", () => { const item: TaskChatProviderActivityItem = { id: "research", kind: "protocol", @@ -43,9 +43,17 @@ describe("TaskChatProtocolActivityRow", () => { status: "completed", title: "Research", summary: "smoked pork shoulder", - details: [{ label: "Query", value: "smoked pork shoulder", mono: true }], + details: [ + { label: "Action", value: "search" }, + { label: "Status", value: "completed" }, + { label: "Query", value: "site:nextjs.org/docs/app/api-reference/config/eslint Next.js 16 eslint.config.mjs core-web-vitals typescript", mono: true }, + ], steps: [], - links: [{ label: "Smoking guide", href: "https://example.com/smoking", description: "Temperature targets" }], + links: Array.from({ length: 6 }, (_, index) => ({ + label: `Next.js configuration result ${index + 1}`, + href: `https://nextjs.org/docs/result-${index + 1}`, + description: "A long provider-authored snippet that stays on one compact line in the activity feed.", + })), children: [], }; render(item); @@ -62,10 +70,17 @@ describe("TaskChatProtocolActivityRow", () => { expect(row?.getAttribute("aria-expanded")).toBe("true"); const detail = container.querySelector('[data-testid="task-chat-protocol-activity-detail"]'); expect(detail?.id).toBe(row?.getAttribute("aria-controls")); - expect(detail?.textContent).toContain("Smoking guide"); + expect(detail?.querySelector('[data-testid="task-chat-research-query"]')?.textContent).toContain("eslint.config.mjs"); + expect(detail?.querySelectorAll('ol[aria-label="Research sources"] li')).toHaveLength(5); + expect(detail?.textContent).toContain("Show 1 more result"); + expect(detail?.textContent).not.toContain("Actionsearch"); + + const showMore = Array.from(detail?.querySelectorAll("button") ?? []).find((button) => button.textContent?.includes("Show 1 more result")); + act(() => showMore?.click()); + expect(detail?.querySelectorAll('ol[aria-label="Research sources"] li')).toHaveLength(6); }); - it("keeps workspace files and diffs inside the row detail", () => { + it("summarizes workspace files without rendering inline diff bodies", () => { render({ id: "workspace", kind: "protocol", @@ -74,16 +89,26 @@ describe("TaskChatProtocolActivityRow", () => { revision: 1, source: "runner_verified", complete: true, - files: [{ path: "ui/src/App.tsx", operation: "modify", previousPath: null, additions: 1, deletions: 1, binary: false, diff: "-old\n+new" }], - totals: { files: 1, additions: 1, deletions: 1 }, + files: Array.from({ length: 10 }, (_, index) => ({ + path: `ui/src/file-${index + 1}.tsx`, + operation: "modify" as const, + previousPath: null, + additions: 1, + deletions: 1, + binary: false, + diff: `-old-${index + 1}\n+new-${index + 1}`, + })), + totals: { files: 10, additions: 10, deletions: 10 }, patchArtifactRef: null, }); const row = container.querySelector('[data-testid="task-chat-protocol-activity-row"] button'); expect(row?.textContent).toContain("Edited files"); act(() => row?.click()); - expect(container.textContent).toContain("ui/src/App.tsx"); - expect(container.textContent).toContain("+new"); + expect(container.textContent).toContain("ui/src/file-1.tsx"); + expect(container.textContent).toContain("2 more files not shown"); + expect(container.textContent).not.toContain("new-1"); + expect(container.querySelector('[data-testid="task-chat-workspace-change-details"] pre')).toBeNull(); }); it("renders durable resources as direct compact links", () => { diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index 516c97f47b1..8608f269d52 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -11,6 +11,7 @@ import { MarkdownBody } from "@/components/MarkdownBody"; import { WorkspaceFileLink } from "@/components/WorkspaceFileLink"; import { cn } from "@/lib/utils"; import type { + TaskChatProtocolDetail, TaskChatProtocolItem, TaskChatProtocolStep, TaskChatProviderActivityItem, @@ -23,6 +24,9 @@ import { protocolActivityPresentation, } from "./task-chat-activity-presentation"; +const COMPACT_RESEARCH_RESULT_LIMIT = 5; +const COMPACT_WORKSPACE_FILE_LIMIT = 8; + function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "interrupted" | "informational" }) { if (status === "running") return ; if (status === "failed" || status === "interrupted") return ; @@ -37,7 +41,91 @@ function stepStatusIcon(status: TaskChatProtocolStep["status"]) { return ; } +function DetailList({ details }: { details: readonly TaskChatProtocolDetail[] }) { + if (details.length === 0) return null; + return ( +
+ {details.map((detail) => ( +
+
{detail.label}
+
{detail.value}
+
+ ))} +
+ ); +} + +function sourceHostname(href: string): string { + try { + return new URL(href).hostname.replace(/^www\./, ""); + } catch { + return href; + } +} + +function ResearchDetails({ item }: { item: TaskChatProviderActivityItem }) { + const [showAllResults, setShowAllResults] = useState(false); + const query = item.details.find((detail) => detail.label.toLowerCase() === "query"); + const additionalDetails = item.details.filter((detail) => !["action", "query", "status"].includes(detail.label.toLowerCase())); + const visibleLinks = showAllResults ? item.links : item.links.slice(0, COMPACT_RESEARCH_RESULT_LIMIT); + const hiddenResultCount = item.links.length - visibleLinks.length; + + return ( +
+ {query ? ( +
+

Query

+

{query.value}

+
+ ) : null} + {item.links.length > 0 ? ( +
+

+ {item.links.length} {item.links.length === 1 ? "result" : "results"} +

+
    + {visibleLinks.map((link, index) => ( +
  1. + + {link.label} + + +

    + {sourceHostname(link.href)} + {link.description ? {link.description} : null} +

    +
  2. + ))} +
+ {hiddenResultCount > 0 ? ( + + ) : showAllResults && item.links.length > COMPACT_RESEARCH_RESULT_LIMIT ? ( + + ) : null} +
+ ) : null} + + {item.output || item.outputTruncated ? ( +

Additional provider output is available in Runner Inspector.

+ ) : null} +
+ ); +} + function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) { + if (item.family === "research") return ; return (
{item.steps.length > 0 ? ( @@ -78,14 +166,7 @@ function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) { ) : null} {item.details.length > 0 ? ( -
- {item.details.map((detail) => ( -
-
{detail.label}
-
{detail.value}
-
- ))} -
+ ) : null} {item.output ? (
{item.output}
@@ -96,12 +177,15 @@ function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) { } function WorkspaceChangeDetails({ item }: { item: TaskChatWorkspaceChangeItem }) { - return item.files.length > 0 ? ( -
    - {item.files.map((file) => ( -
  • - - + if (item.files.length === 0) return

    No changed-file details were reported.

    ; + const visibleFiles = item.files.slice(0, COMPACT_WORKSPACE_FILE_LIMIT); + const hiddenFileCount = item.files.length - visibleFiles.length; + return ( +
    +
      + {visibleFiles.map((file) => ( +
    • + {file.previousPath ? `${file.previousPath} → ${file.path}` : file.path} {file.operation.replaceAll("_", " ")} @@ -110,12 +194,12 @@ function WorkspaceChangeDetails({ item }: { item: TaskChatWorkspaceChangeItem }) {file.additions == null ? "" : `+${file.additions}`} {file.deletions == null ? "" : `−${file.deletions}`} ) : null} - - {file.diff ?
      {file.diff}
      : null} -
    • - ))} -
    - ) :

    No changed-file details were reported.

    ; +
  • + ))} +
+ {hiddenFileCount > 0 ?

{hiddenFileCount} more {hiddenFileCount === 1 ? "file" : "files"} not shown

: null} +
+ ); } function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) { diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.tsx index b3244e7a847..28854fa0eb9 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.tsx @@ -167,9 +167,9 @@ function ProviderActivityCard({ item }: { item: TaskChatProviderActivityItem }) Details {item.details.length > 0 ? ( -
+
{item.details.map((detail) => ( -
+
{detail.label}
{detail.value}
diff --git a/ui/src/components/task-chat/TaskChatToolCard.tsx b/ui/src/components/task-chat/TaskChatToolCard.tsx index 13b4fe311ff..62211ed588d 100644 --- a/ui/src/components/task-chat/TaskChatToolCard.tsx +++ b/ui/src/components/task-chat/TaskChatToolCard.tsx @@ -21,14 +21,14 @@ const STATUS_ICON = { /** * Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] … * [hover chevron] [status]. No card chrome — activity is metadata, not - * content. Clicking toggles the mono result/detail block (left-rail inset); - * diffs render as an inset panel below the row. + * content. Clicking toggles a compact result/change summary (left-rail + * inset). Full diff bodies stay out of the activity feed. */ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) { const { Icon, spin, tone } = STATUS_ICON[item.status]; const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon; const [showDetail, setShowDetail] = useState(false); - const expandable = Boolean(item.detail); + const expandable = Boolean(item.detail || item.diff); return (
@@ -70,35 +70,20 @@ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) { - {expandable && showDetail ? ( + {item.detail && showDetail ? (
           {item.detail}
         
) : null} - {item.diff ? ( -
-
- {item.diff.path ? {item.diff.path} : null} - + {item.diff && showDetail ? ( +
+
+ Changed + {item.diff.path ? {item.diff.path} : null} + +{item.diff.added} −{item.diff.removed}
- {item.diff.lines?.length ? ( -
-              {item.diff.lines.map((l, i) => (
-                
- {l.kind === "add" ? "+" : l.kind === "remove" ? "−" : " "} {l.text} -
- ))} -
- ) : null}
) : null}
diff --git a/ui/src/index.css b/ui/src/index.css index 05f0f8b0a47..933ce2cc4d9 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -2291,6 +2291,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { :root { --gtc-1: 56px 56px 24px minmax(0,1fr); /* Extracted from ui/src/components/DocumentDiffModal.tsx (grid-cols-[56px_56px_24px_minmax(0,1fr)]). */ --gtc-case-revisions: 16rem 1fr; /* Extracted from ui/src/components/CaseRevisionRail.tsx (grid-cols-[16rem_1fr]). */ + --gtc-task-chat-details: 6rem minmax(0,1fr); --gtc-2: auto minmax(0,1fr) 2.25rem; /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[auto_minmax(0,1fr)_2.25rem]). */ --gtc-3: minmax(0,1fr) 2.25rem; /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[minmax(0,1fr)_2.25rem]). */ --gtc-4: minmax(0,1fr); /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[minmax(0,1fr)]). */ diff --git a/ui/storybook/stories/task-runner-protocol.stories.tsx b/ui/storybook/stories/task-runner-protocol.stories.tsx index a82a7f7d93d..cbdf987ac99 100644 --- a/ui/storybook/stories/task-runner-protocol.stories.tsx +++ b/ui/storybook/stories/task-runner-protocol.stories.tsx @@ -234,6 +234,40 @@ const allProviderActivity: TaskChatItem[] = [ providerActivity("provider_notice", "provider.notice.recorded", "informational", { summary: "Throughput temporarily reduced" }), ]; +const searchResultsAndFileChanges: TaskChatItem[] = [ + providerActivity("research", "research.completed", "completed", { + summary: "site:nextjs.org/docs/app/api-reference/config/eslint Next.js 16 eslint.config.mjs core-web-vitals typescript", + details: [ + { label: "Action", value: "search" }, + { label: "Status", value: "completed" }, + { label: "Query", value: "site:nextjs.org/docs/app/api-reference/config/eslint Next.js 16 eslint.config.mjs core-web-vitals typescript", mono: true }, + ], + links: [ + { label: "Configuration: ESLint | Next.js", href: "https://nextjs.org/docs/app/api-reference/config/eslint", description: "Configure ESLint in the App Router and understand the current defaults." }, + { label: "next.config.js: eslint | Next.js", href: "https://nextjs.org/docs/pages/api-reference/config/next-config-js/eslint", description: "Legacy configuration behavior and migration notes." }, + { label: "Configuration: TypeScript | Next.js", href: "https://nextjs.org/docs/app/api-reference/config/typescript", description: "TypeScript configuration options for modern Next.js applications." }, + { label: "Upgrading: Codemods | Next.js", href: "https://nextjs.org/docs/app/guides/upgrading/codemods", description: "Automated migrations for eslint.config.mjs and current conventions." }, + { label: "API Reference: Configuration | Next.js", href: "https://nextjs.org/docs/app/api-reference/config", description: "The complete application configuration reference." }, + { label: "Next.js 16 upgrade guide", href: "https://nextjs.org/docs/app/guides/upgrading/version-16", description: "Breaking changes and recommended upgrade steps for version 16." }, + { label: "Core Web Vitals", href: "https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals", description: "Measure and report application performance metrics." }, + ], + }), + { + id: "file-change-search-story", + kind: "tool", + name: "File change", + rawName: "file_change", + target: "/Users/runner/workspaces/example/tsconfig.json", + status: "completed", + diff: { + path: "tsconfig.json", + added: 228, + removed: 0, + lines: Array.from({ length: 24 }, (_, index) => ({ kind: "add" as const, text: `generated diff line ${index + 1}` })), + }, + }, +]; + function RunnerTransitionStory() { const [phase, setPhase] = useState(0); const items: TaskChatItem[] = phase === 0 @@ -454,6 +488,28 @@ export const RunnerLiveAllProviderFamilies: Story = { }, }; +export const RunnerLiveSearchResultsAndFileChanges: Story = { + name: "Runner Activity / Live / Search Results and File Changes", + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /expand run activity/i })); + const list = canvas.getByTestId("task-chat-runner-activity-list"); + const researchRow = list.querySelector('[data-activity-family="research"]'); + if (!researchRow) throw new Error("Research activity row did not render"); + await userEvent.click(within(researchRow).getByRole("button")); + const toolRow = Array.from(list.querySelectorAll(".tc-enter-tool")).find((row) => row.textContent?.includes("File change")); + if (!toolRow) throw new Error("File change tool row did not render"); + await userEvent.click(within(toolRow).getByRole("button")); + + await expect(canvas.getByTestId("task-chat-research-query")).toHaveTextContent("eslint.config.mjs"); + await expect(canvas.getByRole("list", { name: "Research sources" }).querySelectorAll("li")).toHaveLength(5); + await expect(canvas.getByText("Show 2 more results")).toBeVisible(); + await expect(canvas.getByTestId("task-chat-tool-change-summary")).toHaveTextContent("+228 −0"); + await expect(canvas.queryByText("generated diff line 1")).not.toBeInTheDocument(); + }, +}; + export const RunnerLiveCommentaryAndStreamingUpdates: Story = { name: "Runner Activity / Live / Commentary and Streaming Updates", render: () => ,