diff --git a/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx b/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx index 0774a6d3b853..e2f045609aff 100644 --- a/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx +++ b/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx @@ -65,6 +65,45 @@ const ArtifactPolicyContext = createContext( DEFAULT_CONTEXT_VALUE, ); +function isArtifactActionTool(toolName: string): boolean { + const normalized = toolName.trim().toLowerCase(); + if (/^(write|writing|create|creating|save|saving)\b/.test(normalized)) { + return true; + } + + return ( + normalized.includes("write_file") || + normalized.includes("create_file") || + normalized.includes("save_file") + ); +} + +function buildToolCardDisplay( + candidates: ArtifactPathCandidate[], +): ToolCardDisplay { + if (candidates.length === 0) { + return EMPTY_DISPLAY; + } + + const firstAllowedIndex = candidates.findIndex( + (candidate) => candidate.allowed, + ); + const primaryIndex = firstAllowedIndex === -1 ? 0 : firstAllowedIndex; + const primaryCandidate = candidates[primaryIndex] ?? null; + + if (!primaryCandidate) { + return EMPTY_DISPLAY; + } + + return { + role: "primary_host", + primaryCandidate, + secondaryCandidates: candidates.filter( + (_candidate, index) => index !== primaryIndex, + ), + }; +} + function shortenPath(fullPath: string, homeDir: string | null): string { if (homeDir && fullPath.startsWith(homeDir)) { return `~${fullPath.slice(homeDir.length)}`; @@ -150,18 +189,17 @@ export function ArtifactPolicyProvider({ const displayByToolCallId = new Map(); for (const ranking of artifactsIndex.byMessageId.values()) { - if (!ranking.primaryToolCallId || !ranking.primaryCandidate) continue; - if ( - !ranking.primaryCandidate.toolName || - !isWriteOrientedTool(ranking.primaryCandidate.toolName) - ) { - continue; + for (const [toolCallId, candidates] of ranking.candidatesByToolCallId) { + const toolName = candidates[0]?.toolName; + if (!toolName || !isArtifactActionTool(toolName)) { + continue; + } + + const display = buildToolCardDisplay(candidates); + if (display.role === "primary_host") { + displayByToolCallId.set(toolCallId, display); + } } - displayByToolCallId.set(ranking.primaryToolCallId, { - role: "primary_host", - primaryCandidate: ranking.primaryCandidate, - secondaryCandidates: ranking.secondaryCandidates, - }); } return { diff --git a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx deleted file mode 100644 index 0ea636e86ab9..000000000000 --- a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import type { Message } from "@/shared/types/messages"; -import { - ArtifactPolicyProvider, - useArtifactPolicyContext, -} from "../ArtifactPolicyContext"; - -import { openPath } from "@tauri-apps/plugin-opener"; - -const mockPathExists = vi.fn<(path: string) => Promise>(); - -vi.mock("@/shared/api/system", () => ({ - pathExists: (path: string) => mockPathExists(path), -})); - -function Probe({ - readArgs, - writeArgs, - clonedWriteArgs, -}: { - readArgs: Record; - writeArgs: Record; - clonedWriteArgs: Record; -}) { - const { resolveToolCardDisplay } = useArtifactPolicyContext(); - const readDisplay = resolveToolCardDisplay(readArgs, "read_file"); - const writeDisplay = resolveToolCardDisplay(writeArgs, "write_file"); - const clonedDisplay = resolveToolCardDisplay(clonedWriteArgs, "write_file"); - - return ( -
- {readDisplay.role} - {writeDisplay.role} - - {writeDisplay.primaryCandidate?.resolvedPath ?? ""} - - - {String(writeDisplay.secondaryCandidates.length)} - - {clonedDisplay.role} -
- ); -} - -function TextFollowupProbe({ - writeArgs, -}: { - writeArgs: Record; -}) { - const { resolveToolCardDisplay, getAllSessionArtifacts } = - useArtifactPolicyContext(); - const display = resolveToolCardDisplay( - writeArgs, - "writing markdown file about alphabet history", - ); - const artifacts = getAllSessionArtifacts(); - - return ( -
- {display.role} - - {display.primaryCandidate?.resolvedPath ?? ""} - - - {artifacts.map((artifact) => artifact.resolvedPath).join(",")} - -
- ); -} - -function ReadOnlyProbe({ readArgs }: { readArgs: Record }) { - const { resolveToolCardDisplay, getAllSessionArtifacts } = - useArtifactPolicyContext(); - const display = resolveToolCardDisplay(readArgs, "read_file"); - const artifacts = getAllSessionArtifacts(); - - return ( -
- {display.role} - - {artifacts.map((artifact) => artifact.resolvedPath).join(",")} - -
- ); -} - -function FallbackProbe({ - path = "/Users/test/.goose/projects/sample-project/artifacts/report.md", -}: { - path?: string; -}) { - const { pathExists, openResolvedPath } = useArtifactPolicyContext(); - - return ( -
- - -
- ); -} - -describe("ArtifactPolicyContext", () => { - it("computes one primary host per message and resolves tool cards by args identity", () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - const readArgs = { path: "/Users/test/project-a/notes.md" }; - const writeArgs = { - paths: [ - "/Users/test/project-a/output/final_report.md", - "/Users/test/project-a/output/notes.md", - ], - }; - const messages: Message[] = [ - { - id: "assistant-1", - role: "assistant", - created: Date.now(), - content: [ - { - type: "toolRequest", - id: "tool-1", - name: "read_file", - arguments: readArgs, - status: "completed", - }, - { - type: "toolResponse", - id: "tool-1", - name: "read_file", - result: "Read /Users/test/project-a/notes.md", - isError: false, - }, - { - type: "toolRequest", - id: "tool-2", - name: "write_file", - arguments: writeArgs, - status: "completed", - }, - { - type: "toolResponse", - id: "tool-2", - name: "write_file", - result: "Created /Users/test/project-a/output/final_report.md", - isError: false, - }, - ], - }, - ]; - - render( - - - , - ); - - expect(screen.getByTestId("read-role")).toHaveTextContent("none"); - expect(screen.getByTestId("write-role")).toHaveTextContent("primary_host"); - expect(screen.getByTestId("write-primary")).toHaveTextContent( - "/Users/test/project-a/output/final_report.md", - ); - expect( - Number(screen.getByTestId("write-secondary-count").textContent), - ).toBeGreaterThan(0); - expect(screen.getByTestId("cloned-role")).toHaveTextContent("none"); - }); - - it("does not treat read-only tool paths as session artifacts", () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - const readArgs = { path: "/Users/test/project-a/notes.md" }; - const messages: Message[] = [ - { - id: "assistant-read-only", - role: "assistant", - created: Date.now(), - content: [ - { - type: "toolRequest", - id: "tool-read", - name: "read_file", - arguments: readArgs, - status: "completed", - }, - { - type: "toolResponse", - id: "tool-read", - name: "read_file", - result: "Read /Users/test/project-a/notes.md", - isError: false, - }, - ], - }, - ]; - - render( - - - , - ); - - expect(screen.getByTestId("read-only-role")).toHaveTextContent("none"); - expect(screen.getByTestId("read-only-artifacts")).toHaveTextContent(""); - }); - - it("falls back to the home artifacts root when a project artifacts path is missing", async () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - mockPathExists.mockImplementation( - async (path: string) => path === "/Users/test/.goose/artifacts/report.md", - ); - - render( - - - , - ); - - screen.getByRole("button", { name: "Check path" }).click(); - await waitFor(() => { - expect( - (window as Window & { __artifactExists?: boolean }).__artifactExists, - ).toBe(true); - }); - - screen.getByRole("button", { name: "Open path" }).click(); - await waitFor(() => { - expect(vi.mocked(openPath)).toHaveBeenCalledWith( - "/Users/test/.goose/artifacts/report.md", - ); - }); - }); - - it("falls back from a working-dir artifacts path to the project root when the file lives there", async () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - mockPathExists.mockImplementation( - async (path: string) => - path === "/Users/test/project-a/README_ENHANCED.md", - ); - - render( - - - , - ); - - screen.getByRole("button", { name: "Check path" }).click(); - await waitFor(() => { - expect( - (window as Window & { __artifactExists?: boolean }).__artifactExists, - ).toBe(true); - }); - - screen.getByRole("button", { name: "Open path" }).click(); - await waitFor(() => { - expect(vi.mocked(openPath)).toHaveBeenCalledWith( - "/Users/test/project-a/README_ENHANCED.md", - ); - }); - }); - - it("does not strip /artifacts/ from a parent directory in the path", async () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - // The file lives at the nested artifacts path — the parent `/artifacts/` should NOT be stripped - mockPathExists.mockImplementation( - async (path: string) => - path === "/Users/test/artifacts/project/artifacts/README_ENHANCED.md", - ); - - render( - - - , - ); - - screen.getByRole("button", { name: "Check path" }).click(); - await waitFor(() => { - expect( - (window as Window & { __artifactExists?: boolean }).__artifactExists, - ).toBe(true); - }); - - screen.getByRole("button", { name: "Open path" }).click(); - await waitFor(() => { - expect(vi.mocked(openPath)).toHaveBeenCalledWith( - "/Users/test/artifacts/project/artifacts/README_ENHANCED.md", - ); - }); - }); - - it("falls back correctly when /artifacts/ appears in a parent dir", async () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - // File is NOT at the artifacts path, but IS at the root-stripped path - mockPathExists.mockImplementation( - async (path: string) => - path === "/Users/test/artifacts/project/README.md", - ); - - render( - - - , - ); - - screen.getByRole("button", { name: "Check path" }).click(); - await waitFor(() => { - expect( - (window as Window & { __artifactExists?: boolean }).__artifactExists, - ).toBe(true); - }); - - screen.getByRole("button", { name: "Open path" }).click(); - await waitFor(() => { - expect(vi.mocked(openPath)).toHaveBeenCalledWith( - "/Users/test/artifacts/project/README.md", - ); - }); - }); - - it("uses assistant text after a tool call to populate file actions and the Files tab", () => { - mockPathExists.mockReset(); - vi.mocked(openPath).mockReset(); - const writeArgs = {}; - const messages: Message[] = [ - { - id: "assistant-1", - role: "assistant", - created: Date.now(), - metadata: { userVisible: true, agentVisible: true }, - content: [ - { - type: "toolRequest", - id: "tool-1", - name: "writing markdown file about alphabet history", - arguments: writeArgs, - status: "completed", - }, - { - type: "toolResponse", - id: "tool-1", - name: "writing markdown file about alphabet history", - result: "completed", - isError: false, - }, - { - type: "text", - text: "The file alpha.md has been created at /Users/test/.goose/artifacts/alpha.md.", - }, - ], - }, - ]; - - render( - - - , - ); - - expect(screen.getByTestId("text-followup-role")).toHaveTextContent( - "primary_host", - ); - expect(screen.getByTestId("text-followup-path")).toHaveTextContent( - "/Users/test/.goose/artifacts/alpha.md", - ); - expect(screen.getByTestId("text-followup-artifacts")).toHaveTextContent( - "/Users/test/.goose/artifacts/alpha.md", - ); - }); -}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.tool-card-display.test.tsx b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.tool-card-display.test.tsx new file mode 100644 index 000000000000..30aa16e1d8e9 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.tool-card-display.test.tsx @@ -0,0 +1,220 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { + ArtifactPolicyProvider, + useArtifactPolicyContext, +} from "../ArtifactPolicyContext"; + +import { openPath } from "@tauri-apps/plugin-opener"; + +const mockPathExists = vi.fn<(path: string) => Promise>(); + +vi.mock("@/shared/api/system", () => ({ + pathExists: (path: string) => mockPathExists(path), +})); + +function Probe({ + readArgs, + writeArgs, + clonedWriteArgs, +}: { + readArgs: Record; + writeArgs: Record; + clonedWriteArgs: Record; +}) { + const { resolveToolCardDisplay } = useArtifactPolicyContext(); + const readDisplay = resolveToolCardDisplay(readArgs, "read_file"); + const writeDisplay = resolveToolCardDisplay(writeArgs, "write_file"); + const clonedDisplay = resolveToolCardDisplay(clonedWriteArgs, "write_file"); + + return ( +
+ {readDisplay.role} + {writeDisplay.role} + + {writeDisplay.primaryCandidate?.resolvedPath ?? ""} + + + {String(writeDisplay.secondaryCandidates.length)} + + + {writeDisplay.secondaryCandidates + .map((candidate) => candidate.resolvedPath) + .join(",")} + + {clonedDisplay.role} +
+ ); +} + +function EditWriteProbe({ + editArgs, + writeArgs, +}: { + editArgs: Record; + writeArgs: Record; +}) { + const { resolveToolCardDisplay } = useArtifactPolicyContext(); + const editDisplay = resolveToolCardDisplay(editArgs, "edit_file"); + const writeDisplay = resolveToolCardDisplay(writeArgs, "write_file"); + + return ( +
+ {editDisplay.role} + {writeDisplay.role} + + {writeDisplay.secondaryCandidates + .map((candidate) => candidate.resolvedPath) + .join(",")} + +
+ ); +} + +describe("ArtifactPolicyContext tool card display", () => { + it("resolves tool card displays per tool call and by args identity", () => { + mockPathExists.mockReset(); + vi.mocked(openPath).mockReset(); + const readArgs = { path: "/Users/test/project-a/notes.md" }; + const writeArgs = { + paths: [ + "/Users/test/project-a/output/final_report.md", + "/Users/test/project-a/output/notes.md", + ], + }; + const messages: Message[] = [ + { + id: "assistant-1", + role: "assistant", + created: Date.now(), + content: [ + { + type: "toolRequest", + id: "tool-1", + name: "read_file", + arguments: readArgs, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-1", + name: "read_file", + result: "Read /Users/test/project-a/notes.md", + isError: false, + }, + { + type: "toolRequest", + id: "tool-2", + name: "write_file", + arguments: writeArgs, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-2", + name: "write_file", + result: "Created /Users/test/project-a/output/final_report.md", + isError: false, + }, + ], + }, + ]; + + render( + + + , + ); + + expect(screen.getByTestId("read-role")).toHaveTextContent("none"); + expect(screen.getByTestId("write-role")).toHaveTextContent("primary_host"); + expect(screen.getByTestId("write-primary")).toHaveTextContent( + "/Users/test/project-a/output/final_report.md", + ); + expect( + Number(screen.getByTestId("write-secondary-count").textContent), + ).toBeGreaterThan(0); + expect(screen.getByTestId("write-secondary-paths")).toHaveTextContent( + "/Users/test/project-a/output/notes.md", + ); + expect(screen.getByTestId("write-secondary-paths")).not.toHaveTextContent( + "/Users/test/project-a/notes.md", + ); + expect(screen.getByTestId("cloned-role")).toHaveTextContent("none"); + }); + + it("does not surface artifact actions for edit tool calls in mixed messages", () => { + mockPathExists.mockReset(); + vi.mocked(openPath).mockReset(); + const editArgs = { path: "/Users/test/project-a/README.md" }; + const writeArgs = { + paths: [ + "/Users/test/project-a/output/final_report.md", + "/Users/test/project-a/output/notes.md", + ], + }; + const messages: Message[] = [ + { + id: "assistant-edit-write", + role: "assistant", + created: Date.now(), + content: [ + { + type: "toolRequest", + id: "tool-edit", + name: "edit_file", + arguments: editArgs, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-edit", + name: "edit_file", + result: "Edited /Users/test/project-a/README.md", + isError: false, + }, + { + type: "toolRequest", + id: "tool-write", + name: "write_file", + arguments: writeArgs, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-write", + name: "write_file", + result: "Created /Users/test/project-a/output/final_report.md", + isError: false, + }, + ], + }, + ]; + + render( + + + , + ); + + expect(screen.getByTestId("edit-role")).toHaveTextContent("none"); + expect(screen.getByTestId("write-role")).toHaveTextContent("primary_host"); + expect(screen.getByTestId("write-secondary-paths")).toHaveTextContent( + "/Users/test/project-a/output/notes.md", + ); + expect(screen.getByTestId("write-secondary-paths")).not.toHaveTextContent( + "/Users/test/project-a/README.md", + ); + }); +}); diff --git a/ui/goose2/src/features/chat/lib/toolCallPresentation.ts b/ui/goose2/src/features/chat/lib/toolCallPresentation.ts new file mode 100644 index 000000000000..c540effb07ae --- /dev/null +++ b/ui/goose2/src/features/chat/lib/toolCallPresentation.ts @@ -0,0 +1,190 @@ +import type { ToolCallKind, ToolCallLocation } from "@/shared/types/messages"; + +const COMMAND_KEYS = ["command", "cmd", "script"]; +const SEARCH_KEYS = ["query", "pattern", "search", "needle", "text"]; +const PATH_KEYS = [ + "path", + "file", + "filePath", + "filepath", + "targetPath", + "directory", + "dir", + "cwd", + "folder", +]; +const URL_KEYS = ["url", "uri", "href"]; +const FILE_ORIENTED_KINDS = new Set([ + "read", + "edit", + "delete", + "move", +]); + +export interface ToolInputSummaryRow { + label: string; + value: string; + monospace?: boolean; + title?: string; + renderAs?: "text" | "bash"; +} + +interface ToolCallPresentationInput { + name: string; + kind?: ToolCallKind; + locations?: ToolCallLocation[]; + arguments: Record; +} + +function getStringArgument( + args: Record, + keys: string[], +): string | undefined { + for (const key of keys) { + const value = args[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + + return undefined; +} + +function getNumericArgument( + args: Record, + keys: string[], +): number | undefined { + for (const key of keys) { + const value = args[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + + return undefined; +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function basenameOf(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +function getPrimaryPath( + args: Record, + locations?: ToolCallLocation[], +): string | undefined { + return getStringArgument(args, PATH_KEYS) ?? locations?.[0]?.path; +} + +export function getToolInputSummaryRows({ + name, + kind, + locations, + arguments: args, +}: ToolCallPresentationInput): ToolInputSummaryRow[] { + const command = getStringArgument(args, COMMAND_KEYS); + if (command) { + const cwd = getStringArgument(args, ["cwd"]); + return [ + { + label: "Command", + value: command, + monospace: true, + renderAs: "bash", + }, + ...(cwd + ? [{ label: "Working directory", value: cwd, monospace: true }] + : []), + ]; + } + + const query = getStringArgument(args, SEARCH_KEYS); + if (kind === "search" || query) { + const path = getPrimaryPath(args, locations); + return [ + ...(query ? [{ label: "Query", value: query, monospace: true }] : []), + ...(path ? [{ label: "Path", value: path, monospace: true }] : []), + ]; + } + + const url = getStringArgument(args, URL_KEYS); + if (kind === "fetch" || url) { + return url ? [{ label: "Resource", value: url, monospace: true }] : []; + } + + const path = getPrimaryPath(args, locations); + if ((kind && FILE_ORIENTED_KINDS.has(kind)) || path) { + const line = + getNumericArgument(args, ["line", "startLine"]) ?? locations?.[0]?.line; + const displayPath = path ? basenameOf(path) : undefined; + return [ + ...(path + ? [ + { + label: "Path", + value: displayPath ?? path, + monospace: true, + title: path, + }, + ] + : []), + ...(line ? [{ label: "Line", value: String(line) }] : []), + ]; + } + + if (name.trim().length > 0) { + return [{ label: "Tool", value: name }]; + } + + return []; +} + +export function isFileOrientedToolCall({ + kind, + locations, + arguments: args, +}: Omit): boolean { + if (locations && locations.length > 0) { + return true; + } + + if (kind && FILE_ORIENTED_KINDS.has(kind)) { + return true; + } + + const path = getStringArgument(args, PATH_KEYS); + return Boolean(path && !looksLikeUrl(path)); +} + +export function dedupeToolLocations( + locations?: ToolCallLocation[], +): ToolCallLocation[] { + if (!locations?.length) { + return []; + } + + const deduped = new Map(); + for (const location of locations) { + const key = `${location.path}:${location.line ?? ""}`; + if (!deduped.has(key)) { + deduped.set(key, location); + } + } + + return Array.from(deduped.values()); +} + +export function getToolLocationTitle(location: ToolCallLocation): string { + return location.line + ? `${basenameOf(location.path)}:${location.line}` + : basenameOf(location.path); +} + +export function getToolLocationSubtitle(location: ToolCallLocation): string { + return location.line ? `${location.path}:${location.line}` : location.path; +} diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index ce1d3c74575d..cf7bbc0cedce 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -1,30 +1,18 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; -import { - Copy, - Check, - RotateCcw, - Pencil, - FileText, - FolderClosed, -} from "lucide-react"; +import { Check, FileText, FolderClosed } from "lucide-react"; import { IconRobot } from "@tabler/icons-react"; import { openPath } from "@tauri-apps/plugin-opener"; import { cn } from "@/shared/lib/cn"; import { useLocaleFormatting } from "@/shared/i18n"; import { useAgentStore } from "@/features/agents/stores/agentStore"; -import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { getCatalogEntry } from "@/features/providers/providerCatalog"; import { getProviderIcon, formatProviderLabel, } from "@/shared/ui/icons/ProviderIcons"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; -import { - MessageActions, - MessageAction, - MessageResponse, -} from "@/shared/ui/ai-elements/message"; +import { MessageResponse } from "@/shared/ui/ai-elements/message"; import { Reasoning, ReasoningTrigger, @@ -32,6 +20,7 @@ import { } from "@/shared/ui/ai-elements/reasoning"; import { ToolChainCards, type ToolChainItem } from "./ToolChainCards"; import { ClickableImage } from "./ClickableImage"; +import { MessageBubbleActions } from "./MessageBubbleActions"; import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler"; import type { Message, @@ -97,11 +86,10 @@ interface ContentSection { items: MessageContent[] | ToolChainItem[]; } -/** Keep only content blocks whose audience includes "user" (or has no audience). */ function filterUserVisibleContent(content: MessageContent[]): MessageContent[] { - return content.filter((b) => { - const aud = b.annotations?.audience; - return !aud || aud.length === 0 || aud.includes("user"); + return content.filter((block) => { + const audience = block.annotations?.audience; + return !audience || audience.length === 0 || audience.includes("user"); }); } @@ -138,7 +126,7 @@ function groupContentSections(content: MessageContent[]): ContentSection[] { const flushToolChain = () => { if (currentToolChain.length > 0) { sections.push({ - key: currentToolChain.map((item) => item.key).join(":"), + key: currentToolChain[0]?.key ?? `tool-chain-${sections.length}`, type: "toolChain", items: [...currentToolChain], }); @@ -232,17 +220,29 @@ function renderContentBlock( case "toolResponse": // Handled by groupContentSections toolChain rendering return null; - case "thinking": + case "thinking": { + const th = content as ThinkingContent; + return ( + + + {th.text} + + ); + } case "reasoning": { - const text = (content as ThinkingContent | ReasoningContentType).text; + const r = content as ReasoningContentType; return ( - {text} + {r.text} ); } @@ -281,31 +281,6 @@ function renderContentBlock( } } -function CopyAction({ - copied, - onCopy, -}: { - copied: boolean; - onCopy: () => void; -}) { - const { t } = useTranslation(["chat", "common"]); - - return ( - - {copied ? : } - - ); -} - export const MessageBubble = memo(function MessageBubble({ message, isStreaming, @@ -315,21 +290,16 @@ export const MessageBubble = memo(function MessageBubble({ const { t } = useTranslation(["chat", "common"]); const { formatDate } = useLocaleFormatting(); const { role, content: rawContent, created } = message; - // Only user messages carry annotated blocks; skip the filter for others. const content = - role === "user" ? filterUserVisibleContent(rawContent) : rawContent; + role === "system" ? rawContent : filterUserVisibleContent(rawContent); const { handleContentClick, pathNotice } = useArtifactLinkHandler(); const persona = useAgentStore((state) => message.metadata?.personaId ? state.getPersonaById(message.metadata.personaId) : undefined, ); - const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard(); const personaAvatarUrl = useAvatarSrc(persona?.avatar); - // Skip empty user bubbles (all blocks filtered as assistant-only). - if (role === "user" && content.length === 0) return null; - const textContent = content .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) @@ -349,7 +319,18 @@ export const MessageBubble = memo(function MessageBubble({ ); } + + const messageAttachments = message.metadata?.attachments ?? []; + if (content.length === 0 && messageAttachments.length === 0) { + return null; + } + const isUser = role === "user"; + const hasToolContent = content.some( + (block) => block.type === "toolRequest" || block.type === "toolResponse", + ); + const showAssistantActions = !isUser && !hasToolContent; + const showMessageActions = isUser || showAssistantActions; const assistantProviderId = message.metadata?.providerId; const assistantProviderName = assistantProviderId ? (getCatalogEntry(assistantProviderId)?.displayName ?? @@ -366,7 +347,6 @@ export const MessageBubble = memo(function MessageBubble({ !isUser && (assistantDisplayName || personaAvatarUrl || assistantProviderIcon), ); - const messageAttachments = message.metadata?.attachments ?? []; const timestamp = (
{showAssistantIdentity ? ( @@ -423,7 +404,7 @@ export const MessageBubble = memo(function MessageBubble({ {/* biome-ignore lint/a11y/noStaticElementInteractions: delegated link handler */}
-
- - {isUser && timestamp} - {textContent && ( - copyToClipboard(textContent)} - /> - )} - {!isUser && onRetryMessage && ( - onRetryMessage(message.id)} - > - - - )} - {isUser && onEditMessage && ( - onEditMessage(message.id)} - > - - - )} - {!isUser && timestamp} - -
+ {showMessageActions && ( + + )}
); diff --git a/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx b/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx new file mode 100644 index 000000000000..0c82e1fd6f9f --- /dev/null +++ b/ui/goose2/src/features/chat/ui/MessageBubbleActions.tsx @@ -0,0 +1,100 @@ +import { useTranslation } from "react-i18next"; +import { Copy, Check, RotateCcw, Pencil } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/shared/lib/cn"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { MessageActions, MessageAction } from "@/shared/ui/ai-elements/message"; + +function CopyAction({ + copied, + onCopy, +}: { + copied: boolean; + onCopy: () => void; +}) { + const { t } = useTranslation(["chat", "common"]); + + return ( + + {copied ? : } + + ); +} + +interface MessageBubbleActionsProps { + isUser: boolean; + messageId: string; + textContent: string; + timestamp: ReactNode; + onRetryMessage?: (messageId: string) => void; + onEditMessage?: (messageId: string) => void; +} + +export function MessageBubbleActions({ + isUser, + messageId, + textContent, + timestamp, + onRetryMessage, + onEditMessage, +}: MessageBubbleActionsProps) { + const { t } = useTranslation(["chat", "common"]); + const { isCopied, copyToClipboard } = useCopyToClipboard(); + + return ( +
+ + {isUser && timestamp} + {textContent && ( + copyToClipboard(textContent)} + /> + )} + {!isUser && onRetryMessage && ( + onRetryMessage(messageId)} + > + + + )} + {isUser && onEditMessage && ( + onEditMessage(messageId)} + > + + + )} + {!isUser && timestamp} + +
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/MessageTimeline.tsx b/ui/goose2/src/features/chat/ui/MessageTimeline.tsx index fdf3777c0273..4a2cab9a8b19 100644 --- a/ui/goose2/src/features/chat/ui/MessageTimeline.tsx +++ b/ui/goose2/src/features/chat/ui/MessageTimeline.tsx @@ -187,8 +187,7 @@ export function MessageTimeline({ messageRefs.current[message.id] = el; }} className={cn( - index === 0 ? "mt-0" : "mt-4", - "rounded-xl transition-[background-color,box-shadow]", + "transition-[background-color,box-shadow]", pulsingMessageId === message.id && "bg-accent/25 ring-2 ring-accent/35 ring-inset", )} diff --git a/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx b/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx index 3701c529441b..a6ece6783bd8 100644 --- a/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx +++ b/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx @@ -1,30 +1,52 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { FolderOpen, ChevronRight } from "lucide-react"; +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { FolderOpen, ChevronRight, FileText } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { CodeBlock } from "@/shared/ui/ai-elements/code-block"; import { Tool, ToolHeader, ToolContent, ToolInput, ToolOutput, + ToolSection, + ToolSurface, } from "@/shared/ui/ai-elements/tool"; import { toolStatusMap } from "../lib/toolStatusMap"; -import type { ToolCallStatus } from "@/shared/types/messages"; +import type { + ToolCallKind, + ToolCallLocation, + ToolCallStructuredContent, + ToolCallStatus, +} from "@/shared/types/messages"; import { useArtifactPolicyContext } from "@/features/chat/hooks/ArtifactPolicyContext"; import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy"; +import { + dedupeToolLocations, + getToolInputSummaryRows, + getToolLocationSubtitle, + getToolLocationTitle, + isFileOrientedToolCall, +} from "../lib/toolCallPresentation"; interface ToolCallAdapterProps { name: string; arguments: Record; + kind?: ToolCallKind; + locations?: ToolCallLocation[]; status: ToolCallStatus; result?: string; + content?: ToolCallStructuredContent[]; + rawOutput?: unknown; isError?: boolean; /** Epoch ms when the tool call started executing. */ startedAt?: number; open?: boolean; onOpenChange?: (open: boolean) => void; + showStatusBadge?: boolean; + fitWidth?: boolean; } function useElapsedTime(status: ToolCallStatus, startedAt?: number) { @@ -46,6 +68,116 @@ function useElapsedTime(status: ToolCallStatus, startedAt?: number) { return elapsed; } +function InputSummary({ + rows, + isOpen, +}: { + rows: ReturnType; + isOpen: boolean; +}) { + const commandCodeBlockClasses = + "rounded-none border-0 bg-transparent shadow-none [&>div]:overflow-hidden [&_pre]:m-0 [&_pre]:bg-transparent [&_pre]:p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:text-[12px] [&_pre]:leading-5 [&_code]:font-mono [&_code]:text-[12px] [&_code]:leading-5"; + + if (rows.length === 0) { + return null; + } + + return ( +
+ {rows.map((row) => ( +
+ {row.renderAs === "bash" ? ( +
+
+ {row.label} +
+ +
+ ) : ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ )} +
+ ))} +
+ ); +} + +function ToolLocations({ locations }: { locations: ToolCallLocation[] }) { + const { t } = useTranslation("chat"); + const { pathExists, openResolvedPath, resolveMarkdownHref } = + useArtifactPolicyContext(); + const [openError, setOpenError] = useState(null); + + const openLocation = async (path: string) => { + try { + setOpenError(null); + const candidate = resolveMarkdownHref(path); + if (!candidate?.allowed) { + setOpenError(candidate?.blockedReason || t("tools.pathOutsideRoots")); + return; + } + + const exists = await pathExists(candidate.resolvedPath); + if (!exists) { + setOpenError(t("tools.fileNotFound", { path: candidate.resolvedPath })); + return; + } + await openResolvedPath(candidate.resolvedPath); + } catch (error) { + setOpenError(error instanceof Error ? error.message : String(error)); + } + }; + + return ( + +
+ {locations.map((location) => { + const candidate = resolveMarkdownHref(location.path); + + return ( + + ); + })} +
+ {openError ? ( +

{openError}

+ ) : null} +
+ ); +} + function ArtifactActions({ args, name, @@ -116,7 +248,7 @@ function ArtifactActions({ }; return ( -
+
{!primary.allowed && primary.blockedReason && ( -

+

{primary.blockedReason}

)} @@ -203,42 +335,165 @@ function ArtifactActions({ ); } +function splitHeaderTitle(name: string, fileLabel: string) { + const index = name.toLowerCase().lastIndexOf(fileLabel.toLowerCase()); + if (index === -1) { + return null; + } + + return { + prefix: name.slice(0, index), + fileLabel: name.slice(index, index + fileLabel.length), + suffix: name.slice(index + fileLabel.length), + }; +} + export function ToolCallAdapter({ name, arguments: args, + kind, + locations, status, result, + rawOutput, isError, startedAt, open, onOpenChange, + showStatusBadge = true, + fitWidth = false, }: ToolCallAdapterProps) { const elapsed = useElapsedTime(status, startedAt); const state = toolStatusMap[status]; + const [isToolOpen, setIsToolOpen] = useControllableState({ + prop: open, + defaultProp: false, + onChange: onOpenChange, + }); + const summaryRows = useMemo( + () => getToolInputSummaryRows({ name, kind, locations, arguments: args }), + [args, kind, locations, name], + ); + const visibleLocations = useMemo( + () => dedupeToolLocations(locations), + [locations], + ); + const isFileOriented = isFileOrientedToolCall({ + kind, + locations: visibleLocations, + arguments: args, + }); + const { openResolvedPath, resolveMarkdownHref } = useArtifactPolicyContext(); + const rawResult = + result ?? + (typeof rawOutput === "string" + ? rawOutput + : rawOutput != null + ? JSON.stringify(rawOutput, null, 2) + : undefined); const elapsedSeconds = status === "executing" && elapsed >= 3 ? elapsed : undefined; + const pathSummaryRow = summaryRows.find((row) => row.label === "Path"); + const headerFileLabel = pathSummaryRow?.value; + const headerFilePath = pathSummaryRow?.title ?? pathSummaryRow?.value; + const headerTitleParts = + headerFileLabel && headerFilePath + ? splitHeaderTitle(name, headerFileLabel) + : null; + const headerFileCandidate = useMemo( + () => (headerFilePath ? resolveMarkdownHref(headerFilePath) : null), + [headerFilePath, resolveMarkdownHref], + ); + const canOpenHeaderFile = Boolean( + headerTitleParts && headerFileCandidate?.allowed, + ); return ( -
- +
+ + {headerTitleParts.prefix} + {canOpenHeaderFile ? ( + + ) : ( + {headerTitleParts.fileLabel} + )} + {headerTitleParts.suffix} + + ) : ( + name + ) + } + splitTrigger={canOpenHeaderFile} state={state} showIcon={false} + showStatusBadge={showStatusBadge} elapsedSeconds={elapsedSeconds} + layout={fitWidth ? "fit" : "fill"} /> - {Object.keys(args).length > 0 && } - + + ( + + )} + /> + {isFileOriented ? ( + + ) : ( + + )} + + {isFileOriented && visibleLocations.length > 1 ? ( + + ) : null} + -
); } diff --git a/ui/goose2/src/features/chat/ui/ToolChainCards.tsx b/ui/goose2/src/features/chat/ui/ToolChainCards.tsx index 5ad7cc0a80fe..f705edb65ac6 100644 --- a/ui/goose2/src/features/chat/ui/ToolChainCards.tsx +++ b/ui/goose2/src/features/chat/ui/ToolChainCards.tsx @@ -1,8 +1,16 @@ import { useState } from "react"; -import { ChevronRight } from "lucide-react"; +import { Check, ChevronRight } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { ToolCallAdapter } from "./ToolCallAdapter"; +import { toolStatusMap } from "../lib/toolStatusMap"; +import { + Tool, + ToolContent, + ToolHeader, + ToolStatusIcon, +} from "@/shared/ui/ai-elements/tool"; import type { + ToolCallStatus, ToolRequestContent, ToolResponseContent, } from "@/shared/types/messages"; @@ -13,6 +21,12 @@ export interface ToolChainItem { response?: ToolResponseContent; } +interface ToolItemGroup { + key: string; + chainId?: string; + items: ToolChainItem[]; +} + const INTERNAL_TOOL_PREFIXES = new Set([ "awk", "bash", @@ -39,11 +53,19 @@ const INTERNAL_TOOL_PREFIXES = new Set([ "zsh", ]); +function getToolItemChainId(item: ToolChainItem): string | undefined { + return item.request?.chainId ?? item.response?.chainId; +} + function getToolItemName(item: ToolChainItem): string { return item.request?.name || item.response?.name || "Tool result"; } -function getToolItemStatus(item: ToolChainItem) { +function getToolItemChainSummary(item: ToolChainItem): string | undefined { + return item.response?.chainSummary ?? item.request?.chainSummary; +} + +function getToolItemStatus(item: ToolChainItem): ToolCallStatus { if (item.response) { return item.response.isError ? "error" : "completed"; } @@ -104,10 +126,82 @@ function partitionToolSteps(toolItems: ToolChainItem[]) { return { primaryItems, hiddenItems }; } +function groupToolItems(toolItems: ToolChainItem[]): ToolItemGroup[] { + const groups: ToolItemGroup[] = []; + + for (const item of toolItems) { + const chainId = getToolItemChainId(item); + const currentGroup = groups[groups.length - 1]; + + if (currentGroup && currentGroup.chainId === chainId) { + currentGroup.items.push(item); + continue; + } + + groups.push({ + key: chainId ? `chain-${chainId}-${item.key}` : `group-${item.key}`, + chainId, + items: [item], + }); + } + + return groups; +} + +function getToolGroupTitle(toolItems: ToolChainItem[]): string { + for (let index = toolItems.length - 1; index >= 0; index -= 1) { + const chainSummary = getToolItemChainSummary(toolItems[index]); + if (chainSummary) { + return chainSummary; + } + } + + const displayItem = + toolItems.find((item) => !isLowSignalToolStep(item)) ?? toolItems[0]; + return getToolItemName(displayItem); +} + +function getToolGroupStatus(toolItems: ToolChainItem[]): ToolCallStatus { + if (toolItems.some((item) => getToolItemStatus(item) === "error")) { + return "error"; + } + if (toolItems.some((item) => getToolItemStatus(item) === "stopped")) { + return "stopped"; + } + if (toolItems.some((item) => getToolItemStatus(item) === "executing")) { + return "executing"; + } + if (toolItems.some((item) => getToolItemStatus(item) === "pending")) { + return "pending"; + } + return "completed"; +} + +function formatToolGroupTitle( + title: string, + stepCount: number, + status: ToolCallStatus, +): string { + if (title === "working" || status === "pending" || status === "executing") { + return `working through ${stepCount} ${stepCount === 1 ? "step" : "steps"}`; + } + + return `${title} (${stepCount} ${stepCount === 1 ? "step" : "steps"})`; +} + +function shouldShowGroupedChain(group: ToolItemGroup): boolean { + return Boolean(group.chainId); +} + export function ToolChainCards({ toolItems }: { toolItems: ToolChainItem[] }) { - const [showInternalSteps, setShowInternalSteps] = useState(false); + const [showInternalStepGroups, setShowInternalStepGroups] = useState< + Set + >(new Set()); const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const { primaryItems, hiddenItems } = partitionToolSteps(toolItems); + const [expandedChainOverrides, setExpandedChainOverrides] = useState< + Record + >({}); + const groups = groupToolItems(toolItems); const handleOpenChange = (key: string, open: boolean) => { setExpandedKeys((prev) => { @@ -121,51 +215,183 @@ export function ToolChainCards({ toolItems }: { toolItems: ToolChainItem[] }) { }); }; - const renderToolItem = (item: ToolChainItem) => { + const handleShowInternalStepsChange = (groupKey: string) => { + setShowInternalStepGroups((prev) => { + const next = new Set(prev); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + }; + + const handleChainOpenChange = (groupKey: string, open: boolean) => { + setExpandedChainOverrides((prev) => ({ + ...prev, + [groupKey]: open, + })); + }; + + const renderToolItem = ( + item: ToolChainItem, + itemIndex: number, + totalItems: number, + ) => { const name = getToolItemName(item); const status = getToolItemStatus(item); const { request, response } = item; + const isLast = itemIndex === totalItems - 1; + const isCompleted = status === "completed"; + + return ( +
+
+
+ {!isLast && ( +
+ )} +
+ {isCompleted ? ( +
+
+
+ handleOpenChange(item.key, open)} + showStatusBadge={false} + fitWidth + /> +
+
+ ); + }; + + const renderToolItems = ( + items: ToolChainItem[], + startIndex = 0, + totalItems = items.length, + ) => + items.map((item, index) => + renderToolItem(item, startIndex + index, totalItems), + ); + + const renderToolStepList = (groupKey: string, items: ToolChainItem[]) => { + const { primaryItems, hiddenItems } = partitionToolSteps(items); + const showInternalSteps = showInternalStepGroups.has(groupKey); + const visibleItemCount = showInternalSteps + ? primaryItems.length + hiddenItems.length + : primaryItems.length; return ( - handleOpenChange(item.key, open)} - /> + <> + {renderToolItems(primaryItems, 0, visibleItemCount)} + + {hiddenItems.length > 0 && ( +
+ + + {showInternalSteps && + renderToolItems( + hiddenItems, + primaryItems.length, + visibleItemCount, + )} +
+ )} + ); }; return ( -
- {primaryItems.map((item) => renderToolItem(item))} - - {hiddenItems.length > 0 && ( -
- - - {showInternalSteps && hiddenItems.map((item) => renderToolItem(item))} -
- )} + +
+ {renderToolStepList(group.key, group.items)} +
+
+ + ); + })}
); } diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx index 5f2dff79fc4e..55a00c4f5448 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx @@ -1,11 +1,10 @@ -import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MessageBubble } from "../MessageBubble"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import type { Message } from "@/shared/types/messages"; import { openPath } from "@tauri-apps/plugin-opener"; -const mockWriteText = vi.fn().mockResolvedValue(undefined); vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn(), })); @@ -37,17 +36,6 @@ describe("MessageBubble", () => { beforeEach(() => { useAgentStore.setState({ personas: [] }); vi.mocked(openPath).mockClear(); - mockWriteText.mockClear(); - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { - writeText: mockWriteText, - }, - }); - }); - - afterEach(() => { - vi.useRealTimers(); }); it("renders user message with correct alignment", () => { @@ -77,6 +65,44 @@ describe("MessageBubble", () => { expect(screen.getByText("hello world")).toBeInTheDocument(); }); + it("hides assistant-only blocks from user messages", () => { + render( + , + ); + + expect(screen.queryByText("Internal prompt")).not.toBeInTheDocument(); + expect(screen.getByText("Visible prompt")).toBeInTheDocument(); + }); + + it("hides assistant-only blocks from assistant messages", () => { + render( + , + ); + + expect(screen.queryByText("Internal note")).not.toBeInTheDocument(); + expect(screen.getByText("Visible reply")).toBeInTheDocument(); + }); + it("renders compaction notifications as centered success messages", () => { const { container } = render( { expect(screen.getByText("second block")).toBeInTheDocument(); }); - it("renders a reserved actions tray for assistant messages", () => { - const onRetryMessage = vi.fn(); - const { container } = render( - , - ); - - expect( - container.querySelector('[data-role="assistant-message"] .pb-8'), - ).toBeInTheDocument(); - expect( - container.querySelector( - '[data-role="assistant-message"] [data-role="message-actions"]', - ), - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); - }); - - it("keeps the action tray timestamp on one line", () => { - const { container } = render( - , - ); - - const timestamp = container.querySelector( - '[data-role="assistant-message"] [data-role="message-timestamp"]', - ); - expect(timestamp).toHaveClass("whitespace-nowrap"); - expect(timestamp).toHaveClass("shrink-0"); - }); - - it("anchors assistant and user actions on opposite sides of the timestamp", () => { - const { container } = render( - <> - - - , - ); - - const assistantActions = container.querySelector( - '[data-role="assistant-message"] [data-role="message-actions"]', - ); - const userActions = container.querySelector( - '[data-role="user-message"] [data-role="message-actions"]', - ); - - expect( - Array.from(assistantActions?.firstElementChild?.children ?? []).map( - (element) => element.tagName, - ), - ).toEqual(["BUTTON", "BUTTON", "SPAN"]); - expect( - Array.from(userActions?.firstElementChild?.children ?? []).map( - (element) => element.tagName, - ), - ).toEqual(["SPAN", "BUTTON", "BUTTON"]); - }); - - it("keeps copy confirmation visible until it resets", async () => { - vi.useFakeTimers(); - const { container } = render( - , - ); - - const actions = container.querySelector( - '[data-role="assistant-message"] [data-role="message-actions"]', - ); - expect(actions).toHaveAttribute("data-copy-confirmed", "false"); - const copyButton = screen.getByRole("button", { name: /copy/i }); - expect(copyButton).not.toHaveClass("bg-accent"); - - await act(async () => { - fireEvent.click(copyButton); - await Promise.resolve(); - }); - - expect(mockWriteText).toHaveBeenCalledWith("response"); - expect(actions).toHaveAttribute("data-copy-confirmed", "true"); - expect(copyButton).toHaveClass("bg-accent"); - - await act(async () => { - vi.advanceTimersByTime(1999); - }); - expect(actions).toHaveAttribute("data-copy-confirmed", "true"); - expect(copyButton).toHaveClass("bg-accent"); - - await act(async () => { - vi.advanceTimersByTime(1); - }); - expect(actions).toHaveAttribute("data-copy-confirmed", "false"); - expect(copyButton).not.toHaveClass("bg-accent"); - }); - it("renders tool request content as ToolCallCard", () => { const msg = assistantMessage([ { @@ -430,10 +355,10 @@ describe("MessageBubble", () => { expect(screen.getByTitle("Claude")).toBeInTheDocument(); }); - it("renders identity for an in-progress assistant message with a provider", () => { + it("renders identity for an in-progress assistant message with visible content and a provider", () => { render( = {}): Message { + return { + id: "u1", + role: "user", + created: Date.now(), + content: [{ type: "text", text }], + ...overrides, + }; +} + +function assistantMessage( + content: Message["content"], + overrides: Partial = {}, +): Message { + return { + id: "a1", + role: "assistant", + created: Date.now(), + content, + ...overrides, + }; +} + +describe("MessageBubble actions", () => { + beforeEach(() => { + useAgentStore.setState({ personas: [] }); + mockWriteText.mockClear(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: mockWriteText, + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders a reserved actions tray for pure assistant text messages", () => { + const onRetryMessage = vi.fn(); + const { container } = render( + , + ); + + expect( + container.querySelector('[data-role="assistant-message"] .pb-8'), + ).toBeInTheDocument(); + expect( + container.querySelector( + '[data-role="assistant-message"] [data-role="message-actions"]', + ), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("hides the assistant actions tray when tool content is present", () => { + const { container } = render( + , + ); + + expect( + container.querySelector('[data-role="assistant-message"] .pb-8'), + ).not.toBeInTheDocument(); + expect( + container.querySelector( + '[data-role="assistant-message"] [data-role="message-actions"]', + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /copy/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /retry/i }), + ).not.toBeInTheDocument(); + }); + + it("keeps the action tray timestamp on one line", () => { + const { container } = render( + , + ); + + const timestamp = container.querySelector( + '[data-role="assistant-message"] [data-role="message-timestamp"]', + ); + expect(timestamp).toHaveClass("whitespace-nowrap"); + expect(timestamp).toHaveClass("shrink-0"); + }); + + it("anchors assistant and user actions on opposite sides of the timestamp", () => { + const { container } = render( + <> + + + , + ); + + const assistantActions = container.querySelector( + '[data-role="assistant-message"] [data-role="message-actions"]', + ); + const userActions = container.querySelector( + '[data-role="user-message"] [data-role="message-actions"]', + ); + + expect( + Array.from(assistantActions?.firstElementChild?.children ?? []).map( + (element) => element.tagName, + ), + ).toEqual(["BUTTON", "BUTTON", "SPAN"]); + expect( + Array.from(userActions?.firstElementChild?.children ?? []).map( + (element) => element.tagName, + ), + ).toEqual(["SPAN", "BUTTON", "BUTTON"]); + }); + + it("keeps copy confirmation visible until it resets", async () => { + vi.useFakeTimers(); + const { container } = render( + , + ); + + const actions = container.querySelector( + '[data-role="assistant-message"] [data-role="message-actions"]', + ); + expect(actions).toHaveAttribute("data-copy-confirmed", "false"); + const copyButton = screen.getByRole("button", { name: /copy/i }); + expect(copyButton).not.toHaveClass("bg-accent"); + + await act(async () => { + fireEvent.click(copyButton); + await Promise.resolve(); + }); + + expect(mockWriteText).toHaveBeenCalledWith("response"); + expect(actions).toHaveAttribute("data-copy-confirmed", "true"); + expect(copyButton).toHaveClass("bg-accent"); + + await act(async () => { + vi.advanceTimersByTime(1999); + }); + expect(actions).toHaveAttribute("data-copy-confirmed", "true"); + expect(copyButton).toHaveClass("bg-accent"); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(actions).toHaveAttribute("data-copy-confirmed", "false"); + expect(copyButton).not.toHaveClass("bg-accent"); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubbleToolChains.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubbleToolChains.test.tsx new file mode 100644 index 000000000000..c3d848a12945 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubbleToolChains.test.tsx @@ -0,0 +1,208 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { MessageBubble } from "../MessageBubble"; +import type { Message } from "@/shared/types/messages"; + +function assistantMessage( + content: Message["content"], + overrides: Partial = {}, +): Message { + return { + id: "a1", + role: "assistant", + created: Date.now(), + content, + ...overrides, + }; +} + +describe("MessageBubble tool chains", () => { + it("groups multi-step ACP tool chains behind a parent card", async () => { + const user = userEvent.setup(); + const msg = assistantMessage([ + { + type: "toolRequest", + id: "tool-1", + chainId: "chain-1", + chainSummary: "working", + name: "python3 create_whales.py", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-1", + chainId: "chain-1", + chainSummary: "working", + name: "python3 create_whales.py", + result: "generated draft", + isError: false, + }, + { + type: "toolRequest", + id: "tool-2", + chainId: "chain-1", + chainSummary: "working", + name: "Create PDF about whales", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-2", + chainId: "chain-1", + chainSummary: "working", + name: "Create PDF about whales", + result: "saved whales.pdf", + isError: false, + }, + { + type: "toolRequest", + id: "tool-3", + chainId: "chain-1", + chainSummary: "working", + name: "ls -lh whales.pdf", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-3", + chainId: "chain-1", + chainSummary: "working", + name: "ls -lh whales.pdf", + result: "12K whales.pdf", + isError: false, + }, + { + type: "toolRequest", + id: "tool-4", + chainId: "chain-1", + chainSummary: "reviewing files", + name: "Write whales.pdf", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-4", + chainId: "chain-1", + chainSummary: "reviewing files", + name: "Write whales.pdf", + result: "done", + isError: false, + }, + ]); + + render(); + + expect(screen.getByText("reviewing files (4 steps)")).toBeInTheDocument(); + expect( + screen.queryByText("python3 create_whales.py"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Write whales.pdf")).not.toBeInTheDocument(); + + await user.click(screen.getByText("reviewing files (4 steps)")); + + expect(screen.getByText("Create PDF about whales")).toBeInTheDocument(); + expect(screen.getByText("Write whales.pdf")).toBeInTheDocument(); + expect( + screen.queryByText("python3 create_whales.py"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("ls -lh whales.pdf")).not.toBeInTheDocument(); + expect(screen.getByText("Show internal steps (2)")).toBeInTheDocument(); + + await user.click(screen.getByText("Show internal steps (2)")); + + expect(screen.getByText("python3 create_whales.py")).toBeInTheDocument(); + expect(screen.getByText("ls -lh whales.pdf")).toBeInTheDocument(); + }); + + it("wraps a single ACP tool call in a parent chain and collapses it when complete", async () => { + const user = userEvent.setup(); + const runningMessage = assistantMessage([ + { + type: "toolRequest", + id: "tool-1", + chainId: "chain-1", + chainSummary: "working", + name: "Read main.js", + arguments: {}, + status: "executing", + }, + ]); + + const { rerender } = render(); + + expect(screen.getByText("working through 1 step")).toBeInTheDocument(); + expect(screen.getByText("Read main.js")).toBeInTheDocument(); + + const completedMessage = assistantMessage([ + { + type: "toolRequest", + id: "tool-1", + chainId: "chain-1", + chainSummary: "reviewing files", + name: "Read main.js", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-1", + chainId: "chain-1", + chainSummary: "reviewing files", + name: "Read main.js", + result: "done", + isError: false, + }, + ]); + + rerender(); + + expect(screen.getByText("reviewing files (1 step)")).toBeInTheDocument(); + expect(screen.queryByText("Read main.js")).not.toBeInTheDocument(); + + await user.click(screen.getByText("reviewing files (1 step)")); + + expect(screen.getByText("Read main.js")).toBeInTheDocument(); + }); + + it("shows a failed grouped chain even when another step is still pending", () => { + const msg = assistantMessage([ + { + type: "toolRequest", + id: "tool-1", + chainId: "chain-1", + chainSummary: "working", + name: "Edit main.swift", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-1", + chainId: "chain-1", + chainSummary: "working", + name: "Edit main.swift", + result: "permission denied", + isError: true, + }, + { + type: "toolRequest", + id: "tool-2", + chainId: "chain-1", + chainSummary: "working", + name: "Edit README.md", + arguments: {}, + status: "pending", + }, + ]); + + render(); + + expect(screen.getByText("working through 2 steps")).toBeInTheDocument(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.header-file.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.header-file.test.tsx new file mode 100644 index 000000000000..aeed075b5441 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.header-file.test.tsx @@ -0,0 +1,146 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ToolCardDisplay } from "@/features/chat/hooks/ArtifactPolicyContext"; +import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy"; +import { ToolCallAdapter } from "../ToolCallAdapter"; + +const mockResolveToolCardDisplay = + vi.fn< + ( + args: Record, + name: string, + result?: string, + ) => ToolCardDisplay + >(); +const mockResolveMarkdownHref = + vi.fn<(href: string) => ArtifactPathCandidate | null>(); +const mockPathExists = vi.fn<(path: string) => Promise>(); +const mockOpenResolvedPath = vi.fn<(path: string) => Promise>(); + +vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({ + useArtifactPolicyContext: () => ({ + resolveToolCardDisplay: mockResolveToolCardDisplay, + resolveMarkdownHref: mockResolveMarkdownHref, + pathExists: mockPathExists, + openResolvedPath: mockOpenResolvedPath, + }), +})); + +const EMPTY_DISPLAY: ToolCardDisplay = { + role: "none", + primaryCandidate: null, + secondaryCandidates: [], +}; + +function makeCandidate( + overrides: Partial = {}, +): ArtifactPathCandidate { + return { + id: "c-1", + rawPath: "/project/Sources/main.swift", + resolvedPath: "/project/Sources/main.swift", + source: "arg_key", + confidence: "high", + kind: "file", + allowed: true, + blockedReason: null, + toolCallId: "tool-1", + toolName: "edit_file", + toolCallIndex: 0, + appearanceIndex: 0, + ...overrides, + }; +} + +function renderAdapter( + overrides: Partial[0]> = {}, +) { + return render( + , + ); +} + +beforeEach(() => { + mockResolveToolCardDisplay.mockReset(); + mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); + mockResolveMarkdownHref.mockReset(); + mockResolveMarkdownHref.mockImplementation((href) => + makeCandidate({ + rawPath: href, + resolvedPath: href, + }), + ); + mockPathExists.mockReset(); + mockPathExists.mockResolvedValue(true); + mockOpenResolvedPath.mockReset(); + mockOpenResolvedPath.mockResolvedValue(undefined); +}); + +describe("ToolCallAdapter header file link", () => { + it("opens the file from the header filename without expanding the accordion", async () => { + const user = userEvent.setup(); + + renderAdapter(); + + await user.click(screen.getByRole("button", { name: /open main\.swift/i })); + + expect(mockOpenResolvedPath).toHaveBeenCalledWith( + "/project/Sources/main.swift", + ); + expect(screen.queryByText("Path")).not.toBeInTheDocument(); + }); + + it("does not expose a clickable header filename outside allowed roots", async () => { + const user = userEvent.setup(); + mockResolveMarkdownHref.mockReturnValue( + makeCandidate({ + rawPath: "/etc/passwd", + resolvedPath: "/etc/passwd", + allowed: false, + blockedReason: "Path is outside allowed project/artifacts roots.", + }), + ); + + renderAdapter({ + name: "Edit passwd", + arguments: { path: "/etc/passwd", line: 1 }, + result: "Updated /etc/passwd", + }); + + expect( + screen.queryByRole("button", { name: /open passwd/i }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByText("passwd")); + + expect(mockOpenResolvedPath).not.toHaveBeenCalled(); + expect(screen.getByText("Path")).toBeInTheDocument(); + }); + + it("opens the accordion when clicking the non-file title text", async () => { + const user = userEvent.setup(); + + const { container } = renderAdapter(); + + const titlePrefix = container.querySelector("[data-tool-title-prefix]"); + expect(titlePrefix).toBeTruthy(); + + if (!titlePrefix) { + throw new Error("Expected non-file title text"); + } + + await user.click(titlePrefix); + + expect(mockOpenResolvedPath).not.toHaveBeenCalled(); + expect(screen.getByText("Path")).toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx index 690db965a69a..7565ea58e3f2 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx @@ -1,6 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolCardDisplay } from "@/features/chat/hooks/ArtifactPolicyContext"; import type { ArtifactPathCandidate } from "@/features/chat/lib/artifactPathPolicy"; import { ToolCallAdapter } from "../ToolCallAdapter"; @@ -15,13 +15,15 @@ const mockResolveToolCardDisplay = result?: string, ) => ToolCardDisplay >(); +const mockResolveMarkdownHref = + vi.fn<(href: string) => ArtifactPathCandidate | null>(); const mockPathExists = vi.fn<(path: string) => Promise>(); const mockOpenResolvedPath = vi.fn<(path: string) => Promise>(); vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({ useArtifactPolicyContext: () => ({ resolveToolCardDisplay: mockResolveToolCardDisplay, - resolveMarkdownHref: () => null, + resolveMarkdownHref: mockResolveMarkdownHref, pathExists: mockPathExists, openResolvedPath: mockOpenResolvedPath, }), @@ -55,6 +57,22 @@ function makeCandidate( }; } +beforeEach(() => { + mockResolveToolCardDisplay.mockReset(); + mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); + mockResolveMarkdownHref.mockReset(); + mockResolveMarkdownHref.mockImplementation((href) => + makeCandidate({ + rawPath: href, + resolvedPath: href, + }), + ); + mockPathExists.mockReset(); + mockPathExists.mockResolvedValue(true); + mockOpenResolvedPath.mockReset(); + mockOpenResolvedPath.mockResolvedValue(undefined); +}); + function renderAdapter( overrides: Partial[0]> = {}, ) { @@ -62,8 +80,10 @@ function renderAdapter( , ); @@ -72,6 +92,173 @@ function renderAdapter( // ── tests ──────────────────────────────────────────────────────────── describe("ToolCallAdapter — ArtifactActions", () => { + it("renders a deterministic input summary and reveals raw input on demand", async () => { + const user = userEvent.setup(); + mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); + + renderAdapter({ + name: "read_file", + kind: "read", + arguments: { path: "/project/src/main.ts", line: 12 }, + result: "file contents", + }); + + expect(screen.getByText("Path")).toBeInTheDocument(); + expect(screen.getByText("main.ts")).toBeInTheDocument(); + expect(screen.getByText("Line")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + expect(screen.queryByText("/project/src/main.ts")).not.toBeInTheDocument(); + expect( + screen.queryByText('"path": "/project/src/main.ts"'), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Parameters")).not.toBeInTheDocument(); + expect(screen.queryByText("Raw input")).not.toBeInTheDocument(); + + const rawInputTrigger = screen.getByText("main.ts").closest("button"); + + expect(rawInputTrigger).toBeTruthy(); + + if (!rawInputTrigger) { + throw new Error("Expected raw input trigger"); + } + + await user.click(rawInputTrigger); + + const rawInputPanel = rawInputTrigger + ?.closest('[data-slot="collapsible"]') + ?.querySelector("pre"); + + expect(rawInputPanel).toHaveTextContent('"path": "/project/src/main.ts"'); + }); + + it("renders command input summaries with a clamped preview and expanded bash highlighting", async () => { + const user = userEvent.setup(); + mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); + + const { container } = renderAdapter({ + name: "shell", + kind: "execute", + arguments: { + command: "cat /project/package.json", + cwd: "/project", + }, + result: "package contents", + }); + + expect(screen.getByText("Command")).toBeInTheDocument(); + expect(screen.getByText("cat /project/package.json")).toBeInTheDocument(); + expect(screen.getByText("Working directory")).toBeInTheDocument(); + expect(screen.getByText("/project")).toBeInTheDocument(); + expect(screen.getByText("package contents")).toBeInTheDocument(); + expect(screen.queryByText("Result")).not.toBeInTheDocument(); + const commandPreview = container.querySelector( + "[data-tool-command-preview]", + ); + expect(commandPreview).toBeTruthy(); + expect(commandPreview?.className).toContain("[&_pre]:line-clamp-3"); + expect(container.querySelector('[data-language="bash"]')).toBeTruthy(); + expect(container.querySelector('[data-language="json"]')).toBeFalsy(); + + await user.click(screen.getByText("cat /project/package.json")); + + const commandBlock = container.querySelector('[data-language="bash"]'); + expect(commandBlock).toBeTruthy(); + expect(commandBlock).toHaveTextContent("cat /project/package.json"); + expect(container.querySelector('[data-language="json"]')).toBeFalsy(); + }); + + it("renders single-file responses without a duplicate files section", () => { + mockResolveToolCardDisplay.mockReturnValue(EMPTY_DISPLAY); + + const view = renderAdapter({ + name: "read_file", + kind: "read", + arguments: { path: "/project/src/renderer.js" }, + locations: [{ path: "/project/src/renderer.js", line: 42 }], + rawOutput: "rendered raw output", + result: "flattened result", + }); + + expect(screen.getByText("Path")).toBeInTheDocument(); + expect(screen.getByText("renderer.js")).toBeInTheDocument(); + expect(screen.getByText("Line")).toBeInTheDocument(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("flattened result")).toBeInTheDocument(); + expect(screen.queryByText("Result")).not.toBeInTheDocument(); + expect(screen.queryByText("Raw result")).not.toBeInTheDocument(); + expect(screen.queryByText("Files")).not.toBeInTheDocument(); + expect(screen.queryByText("renderer.js:42")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /open file/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/more outputs/i)).not.toBeInTheDocument(); + expect(view.container.querySelector('[data-language="json"]')).toBeFalsy(); + }); + + it("renders multi-location fallbacks as inline file pills", async () => { + const user = userEvent.setup(); + + renderAdapter({ + name: "read_file", + kind: "read", + arguments: { path: "/project/src/renderer.js", line: 42 }, + locations: [ + { path: "/project/src/renderer.js", line: 42 }, + { path: "/project/src/index.js", line: 12 }, + ], + result: "flattened result", + }); + + expect(screen.getByText("Files")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /renderer\.js:42/i }), + ).toHaveClass("rounded-full"); + expect(screen.getByRole("button", { name: /index\.js:12/i })).toHaveClass( + "rounded-full", + ); + expect( + screen.queryByText("/project/src/index.js:12"), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /index\.js:12/i })); + + expect(mockOpenResolvedPath).toHaveBeenCalledWith("/project/src/index.js"); + }); + + it("disables multi-location pills outside allowed roots", () => { + mockResolveMarkdownHref.mockImplementation((href) => + href === "/project/src/index.js" + ? makeCandidate({ + rawPath: href, + resolvedPath: href, + allowed: false, + blockedReason: "Path is outside allowed project/artifacts roots.", + }) + : makeCandidate({ + rawPath: href, + resolvedPath: href, + }), + ); + + renderAdapter({ + name: "read_file", + kind: "read", + arguments: { path: "/project/src/renderer.js", line: 42 }, + locations: [ + { path: "/project/src/renderer.js", line: 42 }, + { path: "/project/src/index.js", line: 12 }, + ], + result: "flattened result", + }); + + expect( + screen.getByRole("button", { name: /renderer\.js:42/i }), + ).toBeEnabled(); + expect( + screen.getByRole("button", { name: /index\.js:12/i }), + ).toBeDisabled(); + }); + it('renders "Open file" button when primary candidate exists', () => { const primary = makeCandidate(); mockResolveToolCardDisplay.mockReturnValue({ @@ -82,8 +269,10 @@ describe("ToolCallAdapter — ArtifactActions", () => { renderAdapter(); - expect(screen.getByRole("button", { name: /open file/i })).toBeEnabled(); - expect(screen.getByText(primary.rawPath)).toBeInTheDocument(); + const openFileButton = screen.getByRole("button", { name: /open file/i }); + expect(openFileButton).toBeEnabled(); + expect(openFileButton).toHaveTextContent(primary.rawPath ?? ""); + expect(screen.getByText("output.md")).toBeInTheDocument(); }); it("does NOT render artifact actions when display role is none", () => { @@ -116,7 +305,11 @@ describe("ToolCallAdapter — ArtifactActions", () => { expect(toggle).toBeInTheDocument(); // Secondary button not visible initially - expect(screen.queryByText(secondary.rawPath)).not.toBeInTheDocument(); + expect( + within(toggle.closest("div") ?? document.body).queryByText( + secondary.rawPath, + ), + ).not.toBeInTheDocument(); await user.click(toggle); diff --git a/ui/goose2/src/shared/ui/ai-elements/tool.tsx b/ui/goose2/src/shared/ui/ai-elements/tool.tsx index 5927bfae99e0..7b8e51b1efb5 100644 --- a/ui/goose2/src/shared/ui/ai-elements/tool.tsx +++ b/ui/goose2/src/shared/ui/ai-elements/tool.tsx @@ -1,3 +1,4 @@ +import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { Collapsible, CollapsibleContent, @@ -13,24 +14,65 @@ import { WrenchIcon, XCircleIcon, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; -import { isValidElement } from "react"; +import { + createContext, + isValidElement, + useEffect, + useContext, + useMemo, + useRef, + useState, +} from "react"; import { CodeBlock } from "./code-block"; export type ToolProps = ComponentProps; -export const Tool = ({ className, ...props }: ToolProps) => ( - -); +interface ToolContextValue { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +} + +const ToolContext = createContext(null); + +export const Tool = ({ + className, + open, + defaultOpen = false, + onOpenChange, + ...props +}: ToolProps) => { + const [isOpen, setIsOpen] = useControllableState({ + defaultProp: defaultOpen, + onChange: onOpenChange, + prop: open, + }); + const value = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]); + + return ( + + + + ); +}; export type ToolPart = ToolUIPart | DynamicToolUIPart; export type ToolHeaderProps = { - title?: string; + title?: ReactNode; + splitTrigger?: boolean; className?: string; showIcon?: boolean; + showStatusBadge?: boolean; elapsedSeconds?: number; + layout?: "fill" | "fit"; } & ( | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never } | { @@ -50,22 +92,61 @@ const statusLabels: Record = { "output-error": "Error", }; -const statusIcons: Record = { - "approval-requested": , - "approval-responded": , - "input-available": , - "input-streaming": , - "output-available": , - "output-denied": , - "output-error": , +const statusIconComponents: Record = { + "approval-requested": ClockIcon, + "approval-responded": CheckCircleIcon, + "input-available": ClockIcon, + "input-streaming": CircleIcon, + "output-available": CheckCircleIcon, + "output-denied": XCircleIcon, + "output-error": XCircleIcon, +}; + +const statusIconClasses: Record = { + "approval-requested": "text-yellow-600", + "approval-responded": "text-blue-600", + "input-available": "animate-pulse", + "input-streaming": "", + "output-available": "text-green-600", + "output-denied": "text-orange-600", + "output-error": "text-red-600", }; -export const getStatusBadge = (status: ToolPart["state"]) => { +export const ToolStatusIcon = ({ + status, + className, +}: { + status: ToolPart["state"]; + className?: string; +}) => { + const Icon = statusIconComponents[status]; + + return ( +