From 2711d4e426b29766d3b9075a37f3354ae47d0d82 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 20 Jul 2026 16:05:49 -0500 Subject: [PATCH] feat: web search tool details --- .../components/chat/MessagesTimeline.test.tsx | 51 +++++++ .../src/components/chat/MessagesTimeline.tsx | 54 +------ .../src/components/chat/workEntryDetails.ts | 138 ++++++++++++++++++ apps/web/src/session-logic.test.ts | 11 ++ apps/web/src/session-logic.ts | 2 +- 5 files changed, 202 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/components/chat/workEntryDetails.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..28899e6f2f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -459,6 +459,57 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); + it("renders web search queries compactly and includes details in the expanded body", async () => { + const [{ MessagesTimeline }, { buildToolCallExpandedBody }] = await Promise.all([ + import("./MessagesTimeline"), + import("./workEntryDetails"), + ]); + const workEntry = { + id: "web-search-work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Web search", + tone: "tool" as const, + toolTitle: "Web search", + itemType: "web_search" as const, + toolLifecycleStatus: "completed" as const, + toolData: { + type: "webSearch", + id: "web-search-1", + query: "OpenAI Codex web search payload preview", + action: { + type: "search", + queries: ["OpenAI Codex web search payload", "OpenAI Codex web search result payload"], + }, + results: [{ title: "Codex docs", url: "https://example.com/codex" }], + }, + }; + const markup = renderToStaticMarkup( + , + ); + const expandedBody = buildToolCallExpandedBody(workEntry, undefined); + + expect(markup).toContain("Web search"); + expect(markup).toContain("OpenAI Codex web search payload +1 more"); + expect(markup).toContain("lucide-globe"); + expect(expandedBody).toContain('"query": "OpenAI Codex web search payload preview"'); + expect(expandedBody).toContain('"queries": ['); + expect(expandedBody).toContain('"OpenAI Codex web search result payload"'); + expect(expandedBody).not.toContain("Web search\n"); + expect(expandedBody).not.toContain("Action: search"); + expect(expandedBody).not.toContain("Raw item"); + expect(expandedBody).toContain("https://example.com/codex"); + }); + it("renders review comment contexts as structured cards instead of raw tags", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 61d7855844d..8ea3255b8df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -104,6 +104,7 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { buildToolCallExpandedBody, workEntryPreview } from "./workEntryDetails"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { @@ -1796,59 +1797,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { }; } -function workEntryPreview( - workEntry: Pick, - workspaceRoot: string | undefined, -) { - if (workEntry.command) return workEntry.command; - if (workEntry.detail) return workEntry.detail; - if ((workEntry.changedFiles?.length ?? 0) === 0) return null; - const [firstPath] = workEntry.changedFiles ?? []; - if (!firstPath) return null; - const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); - return workEntry.changedFiles!.length === 1 - ? displayPath - : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; -} - -function workEntryRawCommand( - workEntry: Pick, -): string | null { - const rawCommand = workEntry.rawCommand?.trim(); - if (!rawCommand || !workEntry.command) { - return null; - } - return rawCommand === workEntry.command.trim() ? null : rawCommand; -} - -function buildToolCallExpandedBody( - workEntry: TimelineWorkEntry, - workspaceRoot: string | undefined, -): string | null { - const blocks: string[] = []; - if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { - blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); - } - const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - blocks.push(raw.trim()); - } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); - } - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); - } - const changedFiles = workEntry.changedFiles ?? []; - if (changedFiles.length > 0) { - blocks.push( - changedFiles - .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) - .join("\n"), - ); - } - return blocks.length > 0 ? blocks.join("\n\n") : null; -} - function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || diff --git a/apps/web/src/components/chat/workEntryDetails.ts b/apps/web/src/components/chat/workEntryDetails.ts new file mode 100644 index 00000000000..3767f5bde9c --- /dev/null +++ b/apps/web/src/components/chat/workEntryDetails.ts @@ -0,0 +1,138 @@ +import type { WorkLogEntry } from "../../session-logic"; +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; + +function asPlainRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asTrimmedPlainString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function webSearchActionRecord(toolData: unknown): Record | null { + return asPlainRecord(asPlainRecord(toolData)?.action); +} + +function webSearchQueries(toolData: unknown): string[] { + const item = asPlainRecord(toolData); + const action = webSearchActionRecord(toolData); + if (Array.isArray(action?.queries)) { + const queries: string[] = []; + for (const query of action.queries) { + const trimmed = asTrimmedPlainString(query); + if (trimmed) { + queries.push(trimmed); + } + } + if (queries.length > 0) { + return queries; + } + } + + const actionQuery = asTrimmedPlainString(action?.query); + if (actionQuery) { + return [actionQuery]; + } + + const itemQuery = asTrimmedPlainString(item?.query); + if (itemQuery) { + return [itemQuery]; + } + return []; +} + +function webSearchPreview(toolData: unknown): string | null { + const action = webSearchActionRecord(toolData); + const actionType = asTrimmedPlainString(action?.type); + const queries = webSearchQueries(toolData); + if (queries.length > 0) { + const [firstQuery] = queries; + if (!firstQuery) { + return null; + } + return queries.length === 1 ? firstQuery : `${firstQuery} +${queries.length - 1} more`; + } + const url = asTrimmedPlainString(action?.url); + const pattern = asTrimmedPlainString(action?.pattern); + if ((actionType === "findInPage" || actionType === "find_in_page") && pattern && url) { + return `${pattern} in ${url}`; + } + return pattern ?? url; +} + +function formatWebSearchExpandedBody(toolData: unknown): string | null { + try { + return JSON.stringify(toolData, null, 2); + } catch { + return null; + } +} + +export function workEntryPreview( + workEntry: Pick, + workspaceRoot: string | undefined, +) { + if (workEntry.command) return workEntry.command; + if (workEntry.itemType === "web_search") { + const preview = webSearchPreview(workEntry.toolData); + if (preview) return preview; + } + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); + return workEntry.changedFiles!.length === 1 + ? displayPath + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; +} + +function workEntryRawCommand( + workEntry: Pick, +): string | null { + const rawCommand = workEntry.rawCommand?.trim(); + if (!rawCommand || !workEntry.command) { + return null; + } + return rawCommand === workEntry.command.trim() ? null : rawCommand; +} + +export function buildToolCallExpandedBody( + workEntry: WorkLogEntry, + workspaceRoot: string | undefined, +): string | null { + const blocks: string[] = []; + if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { + blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); + } + if (workEntry.itemType === "web_search" && workEntry.toolData !== undefined) { + const body = formatWebSearchExpandedBody(workEntry.toolData); + if (body) { + blocks.push(body); + } + } + const raw = workEntryRawCommand(workEntry); + if (raw?.trim()) { + blocks.push(raw.trim()); + } else if (workEntry.command?.trim()) { + blocks.push(workEntry.command.trim()); + } + if (workEntry.detail?.trim()) { + blocks.push(workEntry.detail.trim()); + } + const changedFiles = workEntry.changedFiles ?? []; + if (changedFiles.length > 0) { + blocks.push( + changedFiles + .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) + .join("\n"), + ); + } + return blocks.length > 0 ? blocks.join("\n\n") : null; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..d0ed9817c66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1166,6 +1166,15 @@ describe("deriveWorkLogEntries", () => { }); it("uses grep raw output summaries instead of repeating the generic tool label", () => { + const searchItem = { + type: "webSearch", + id: "web-search-1", + query: "React 19 hydration warnings", + action: { + type: "search", + queries: ["React 19 hydration warnings"], + }, + }; const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "grep-update", @@ -1179,6 +1188,7 @@ describe("deriveWorkLogEntries", () => { data: { toolCallId: "tool-grep-1", kind: "search", + item: searchItem, rawInput: {}, }, }, @@ -1211,6 +1221,7 @@ describe("deriveWorkLogEntries", () => { toolTitle: "grep", detail: "19 files", itemType: "web_search", + toolData: searchItem, }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..1cff0de41ca 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -734,7 +734,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } - if (itemType === "mcp_tool_call") { + if (itemType === "mcp_tool_call" || itemType === "web_search") { const data = asRecord(payload?.data); if (data?.item !== undefined) { entry.toolData = data.item;