diff --git a/src/features/workspaces/ai/ai-tool-registry.ts b/src/features/workspaces/ai/ai-tool-registry.ts index 37ac6637..429ee3f2 100644 --- a/src/features/workspaces/ai/ai-tool-registry.ts +++ b/src/features/workspaces/ai/ai-tool-registry.ts @@ -43,7 +43,7 @@ export const AI_TOOL_REGISTRY = defineAiToolRegistry({ title: "Calculate time", visibility: "hidden", }), - workspace_list_items: readTool({ icon: "file", title: "List workspace" }), + workspace_list_items: readTool({ icon: "file", title: "List workspace", visibility: "hidden" }), workspace_read_items: readTool({ icon: "file", title: "Read workspace" }), workspace_rename_item: writeTool({ icon: "edit", title: "Rename item" }), workspace_move_items: writeTool({ icon: "edit", title: "Move items" }), diff --git a/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx b/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx index 9760f4d0..aa387da9 100644 --- a/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatToolActivityRow.tsx @@ -147,24 +147,22 @@ function ActivitySummary({ }) { const isRunning = activity.status === "running"; const { presentation } = activity; - const label = `${presentation.title}: ${activity.summary}`; return (
- {presentation.title} + {activity.summary} - {activity.summary} {canExpand ? ( diff --git a/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts index 17841ef5..0b90ff58 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts @@ -16,9 +16,9 @@ describe("Code Mode tool groups", () => { { seq: 1, connector: "tools", - method: "workspace_list_items", - args: { path: "/" }, - result: { items: [{ path: "/Notes" }] }, + method: "web_search", + args: { query: "notes" }, + result: { results: [] }, requiresApproval: false, state: "applied", }, @@ -36,15 +36,15 @@ describe("Code Mode tool groups", () => { expect(group.children).toEqual([ { - id: "1:tools:workspace_list_items", + id: "1:tools:web_search", presentation: { - icon: "file", - title: "List workspace", + icon: "search", + title: "Search web", visibility: "visible", }, status: "completed", - summary: "Listed 1 item", - toolName: "workspace_list_items", + summary: "Found 0 sources for “notes”", + toolName: "web_search", }, ]); expect(parts[1]).toMatchObject({ toolCallId: "direct-tool-1" }); @@ -54,11 +54,11 @@ describe("Code Mode tool groups", () => { const message = createMessage([ createOrchestratePart({ status: "completed", executionId: "execution-1", result: null }), { - type: "tool-workspace_list_items", + type: "tool-web_search", toolCallId: "tool-1", state: "output-available", - input: { path: "/" }, - output: { items: [] }, + input: { query: "hello" }, + output: { results: [] }, }, ]); @@ -68,13 +68,13 @@ describe("Code Mode tool groups", () => { { id: "tool-1", presentation: { - icon: "file", - title: "List workspace", + icon: "search", + title: "Search web", visibility: "visible", }, status: "completed", - summary: "Listed 0 items", - toolName: "workspace_list_items", + summary: "Found 0 sources for “hello”", + toolName: "web_search", }, ]); }); diff --git a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts index 5112f104..afa454bf 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-tool-receipts.ts @@ -21,22 +21,33 @@ export function getRunningToolReceipt(input: { return running(`Deleting ${formatCount(getArray(toolInput.paths).length, "item")}`); case "workspace_edit_item": return running(`Editing ${quoteName(getBaseName(getString(toolInput.path)))}`); - case "workspace_list_items": - return running(`Listing ${formatPath(getString(toolInput.path) ?? "/")}`); - case "workspace_move_items": - return running(`Moving ${formatCount(getArray(toolInput.paths).length, "item")}`); - case "workspace_read_items": + case "workspace_move_items": { + const count = getArray(toolInput.paths).length; + const destination = formatDestinationName(getString(toolInput.destinationPath)); + const target = formatCount(count, "item"); + return running(destination ? `Moving ${target} to ${destination}` : `Moving ${target}`); + } + case "workspace_read_items": { + const requests = getArray(toolInput.requests).map((request) => asRecord(request)); + const paths = requests.map((request) => getString(request.path)).filter(Boolean) as string[]; + const target = formatToolInputPaths(paths); + if (requests.length === 1) { + const range = formatPageRange(getString(requests[0]?.range)); + return running(range ? `Reading ${target} p. ${range}` : `Reading ${target}`); + } + return running(`Reading ${target}`); + } + case "workspace_rename_item": { + const oldName = quoteName(getBaseName(getString(toolInput.path))); + const newName = getString(toolInput.name); return running( - `Reading ${formatToolInputPaths( - getArray(toolInput.requests).map((request) => asRecord(request).path), - )}`, + newName ? `Renaming ${oldName} → ${quoteName(newName)}` : `Renaming ${oldName}`, ); - case "workspace_rename_item": - return running(`Renaming ${quoteName(getBaseName(getString(toolInput.path)))}`); + } case "web_links": return running(`Finding links on ${formatUrl(getString(toolInput.url))}`); case "web_markdown": - return running(`Reading ${formatUrl(getString(toolInput.url))}`); + return running(`Reading ${formatUrlWithPath(getString(toolInput.url))}`); case "web_search": return running(`Searching for ${quoteName(getString(toolInput.query))}`); case "research_deepen": @@ -48,7 +59,7 @@ export function getRunningToolReceipt(input: { case "orchestrate": return running("Working through the task"); default: - return running("Working"); + return running(`Running ${formatToolNameFallback(input.toolName)}`); } } @@ -65,6 +76,8 @@ export function getFinishedToolReceipt(input: { }; } + const unknownFallback = () => completed(summarizeUnknownResult(input.output, input.toolName)); + switch (input.toolName) { case "workspace_create_items": return summarizeWorkspaceBatch(input.output, { @@ -82,22 +95,18 @@ export function getFinishedToolReceipt(input: { return summarizeWorkspaceBatch(input.output, { failureVerb: "move", successVerb: "Moved", + destination: formatDestinationName(getString(asRecord(input.toolInput).destinationPath)), }); case "workspace_rename_item": - return summarizeWorkspaceSingleItem(input.output, { - failureVerb: "rename", - successVerb: "Renamed", - }); + return summarizeWorkspaceRename(input.output, input.toolInput); case "workspace_edit_item": return summarizeWorkspaceEdit(input.output, input.toolInput); - case "workspace_list_items": - return summarizeWorkspaceList(input.output); case "workspace_read_items": return summarizeWorkspaceRead(input.output); case "web_search": return completed(summarizeWebSearch(input.output, input.toolInput)); case "web_markdown": - return completed(`Read ${formatUrl(getString(asRecord(input.toolInput).url))}`); + return completed(`Read ${formatUrlWithPath(getString(asRecord(input.toolInput).url))}`); case "web_links": return completed(summarizeWebLinks(input.output, input.toolInput)); case "research_discover": @@ -109,7 +118,7 @@ export function getFinishedToolReceipt(input: { case "compute": return summarizeCompute(input.output); default: - return completed(summarizeUnknownResult(input.output)); + return unknownFallback(); } } @@ -138,10 +147,6 @@ function summarizeFailedTool(toolName: string, output: unknown, toolInput: unkno return `Couldn’t update ${quoteName( getBaseName(getString(outputRecord.path) ?? getPathFromToolInput(toolInput)), )}`; - case "workspace_list_items": - return failedCount > 0 - ? `Couldn’t list ${formatCount(failedCount, "item")}` - : "Couldn’t list workspace"; case "workspace_read_items": return failedCount > 0 ? `Couldn’t read ${formatCount(failedCount, "item")}` @@ -149,7 +154,7 @@ function summarizeFailedTool(toolName: string, output: unknown, toolInput: unkno case "compute": return "Couldn’t compute"; default: - return "Couldn’t complete"; + return `${capitalize(formatToolNameFallback(toolName))} failed`; } } @@ -158,6 +163,7 @@ function summarizeWorkspaceBatch( options: { failureVerb: string; successVerb: string; + destination?: string; typeFromItem?: (item: unknown) => string; }, ): AiChatToolReceipt { @@ -169,37 +175,33 @@ function summarizeWorkspaceBatch( return failed(`Couldn’t ${options.failureVerb} ${formatCount(failedCount, "item")}`); } - const successSummary = + const base = items.length === 1 ? summarizeSingleWorkspaceItem(items[0], options) : items.length === 2 ? `${options.successVerb} ${joinNames(items, "item")}` : `${options.successVerb} ${formatCount(items.length, "item")}`; + const successSummary = options.destination ? `${base} to ${options.destination}` : base; + return completed(appendFailureCount(successSummary, failedCount)); } -function summarizeWorkspaceSingleItem( - output: unknown, - options: { - failureVerb: string; - successVerb: string; - }, -): AiChatToolReceipt { +function summarizeWorkspaceRename(output: unknown, toolInput: unknown): AiChatToolReceipt { const record = asRecord(output); const item = asRecord(record.item); const failedCount = getArray(record.failed).length; if (!record.item && failedCount > 0) { - return failed(`Couldn’t ${options.failureVerb} ${formatCount(failedCount, "item")}`); + return failed(`Couldn’t rename ${formatCount(failedCount, "item")}`); } - return completed( - appendFailureCount( - `${options.successVerb} ${quoteName(getBaseName(getString(item.path)))}`, - failedCount, - ), + const oldName = quoteName(getBaseName(getString(item.previousPath))); + const newName = quoteName( + getBaseName(getString(item.path) ?? getString(asRecord(toolInput).name)), ); + + return completed(appendFailureCount(`Renamed ${oldName} → ${newName}`, failedCount)); } function summarizeSingleWorkspaceItem( @@ -218,6 +220,7 @@ function summarizeSingleWorkspaceItem( function summarizeWorkspaceEdit(output: unknown, toolInput: unknown): AiChatToolReceipt { const record = asRecord(output); const failedCount = getArray(record.failed).length; + const warningCount = getArray(record.warnings).length; const appliedCount = getNumber(record.applied) ?? 0; if (appliedCount === 0 && failedCount > 0) { @@ -236,19 +239,10 @@ function summarizeWorkspaceEdit(output: unknown, toolInput: unknown): AiChatTool )}` : `Updated ${quoteName(getBaseName(getString(record.path)))}`; - return completed(appendFailureCount(summary, failedCount)); -} - -function summarizeWorkspaceList(output: unknown): AiChatToolReceipt { - const record = asRecord(output); - const items = getArray(record.items); - const failedCount = getArray(record.failed).length; - - if (items.length === 0 && failedCount > 0) { - return failed(`Couldn’t list ${formatCount(failedCount, "item")}`); - } - - return completed(appendFailureCount(`Listed ${formatCount(items.length, "item")}`, failedCount)); + const withFailures = appendFailureCount(summary, failedCount); + return completed( + warningCount > 0 ? `${withFailures}, ${formatCount(warningCount, "warning")}` : withFailures, + ); } function summarizeWorkspaceRead(output: unknown): AiChatToolReceipt { @@ -274,7 +268,7 @@ function summarizeWorkspaceRead(output: unknown): AiChatToolReceipt { const summary = readyItems.length === 1 - ? `Read ${quoteName(getBaseName(getString(asRecord(readyItems[0]).path)))}` + ? formatSingleReadSummary(readyItems[0]) : `Read ${formatCount(readyItems.length, "item")}`; const pendingSummary = @@ -284,6 +278,29 @@ function summarizeWorkspaceRead(output: unknown): AiChatToolReceipt { return completed(appendFailureCount(pendingSummary, failedCount)); } +function formatSingleReadSummary(result: unknown) { + const record = asRecord(result); + const name = quoteName(getBaseName(getString(record.path))); + const location = asRecord(record.location); + if (getString(location.kind) === "pages") { + const range = formatPageRange(getString(location.requested)); + const total = getNumber(location.total); + const returned = getArray(location.returned).length; + if (range) { + return total && returned < total + ? `Read ${name} p. ${range} of ${total}` + : `Read ${name} p. ${range}`; + } + } + return `Read ${name}`; +} + +function formatPageRange(range: string | undefined) { + if (!range) return undefined; + const trimmed = range.trim(); + return trimmed ? trimmed.replace(/\s*-\s*/g, "–") : undefined; +} + function summarizeWebSearch(output: unknown, toolInput: unknown) { const results = getArray(asRecord(output).results); return appendSubject(`Found ${formatCount(results.length, "source")}`, asRecord(toolInput).query); @@ -313,7 +330,7 @@ function summarizeResearchDeepen(output: unknown, toolInput: unknown) { return `Found ${formatCount(record.papers.length, "paper")} related to ${quoteName(paper)}`; } - return summarizeUnknownResult(output); + return summarizeUnknownResult(output, "research_deepen"); } function summarizeRunningResearchDeepen(input: Record) { @@ -340,14 +357,14 @@ function summarizeCodemode(output: unknown): AiChatToolReceipt { } if (status === "error") { - return failed("Couldn’t complete"); + return failed("Couldn’t work through the task"); } if (status === "completed") { - return completed(summarizeUnknownResult(record.result)); + return completed(summarizeUnknownResult(record.result, "orchestrate")); } - return completed(summarizeUnknownResult(output)); + return completed(summarizeUnknownResult(output, "orchestrate")); } function summarizeCompute(output: unknown): AiChatToolReceipt { @@ -386,7 +403,7 @@ function summarizeCompute(output: unknown): AiChatToolReceipt { ); } -function summarizeUnknownResult(output: unknown) { +function summarizeUnknownResult(output: unknown, toolName: string) { const record = asRecord(output); if (Array.isArray(record.items)) { @@ -409,7 +426,16 @@ function summarizeUnknownResult(output: unknown) { return "Read 1 page"; } - return "Done"; + return `Ran ${formatToolNameFallback(toolName)}`; +} + +function formatToolNameFallback(toolName: string) { + const words = toolName.split(/[_-]+/).filter(Boolean); + return words.length > 0 ? words.join(" ") : toolName; +} + +function capitalize(value: string) { + return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; } function completed(summary: string): AiChatToolReceipt { @@ -479,10 +505,6 @@ function getPathFromToolInput(input: unknown) { return getString(asRecord(input).path); } -function formatPath(path: string) { - return path === "/" ? "workspace root" : quoteName(path); -} - function formatToolInputPaths(value: unknown) { const paths = getArray(value) .map((item) => getString(item)) @@ -507,6 +529,28 @@ function formatUrl(url: string | undefined) { } } +function formatUrlWithPath(url: string | undefined) { + if (!url) { + return "page"; + } + + try { + const parsed = new URL(url); + const hostname = parsed.hostname.replace(/^www\./, ""); + const path = parsed.pathname.replace(/\/$/, ""); + return truncateReceiptValue(path ? `${hostname}${path}` : hostname); + } catch { + return truncateReceiptValue(url); + } +} + +function formatDestinationName(path: string | undefined) { + if (!path) return undefined; + if (path === "/") return "workspace root"; + const name = getBaseName(path); + return name ? quoteName(name) : undefined; +} + function truncateReceiptValue(value: string) { const normalized = value.replace(/\s+/g, " ").trim();