From 8f73ef9f6c67d40aa829012c482362f533671470 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 21 Apr 2026 09:22:41 -0700 Subject: [PATCH] feat: goose2 message bubble + action tray (#8720) Signed-off-by: Taylor Ho --- ui/goose2/scripts/check-file-sizes.mjs | 5 + .../src/features/chat/ui/MessageBubble.tsx | 125 ++++++++++------- .../chat/ui/__tests__/MessageBubble.test.tsx | 128 +++++++++++++++++- 3 files changed, 206 insertions(+), 52 deletions(-) diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 9d2b3c4b..15460b7e 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -65,6 +65,11 @@ const EXCEPTIONS = { justification: "Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.", }, + "src/features/chat/ui/MessageBubble.tsx": { + limit: 520, + justification: + "Bubble rendering still owns assistant identity, grouped tool output, attachments, and the inline actions tray pending a later extraction pass.", + }, "src/features/chat/ui/__tests__/ChatInput.test.tsx": { limit: 520, justification: diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index 6363f98c..e82b45bf 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -1,11 +1,10 @@ -import { useState, memo } from "react"; +import { memo } from "react"; import { useTranslation } from "react-i18next"; import { Copy, Check, RotateCcw, Pencil, - User, FileText, FolderClosed, } from "lucide-react"; @@ -14,6 +13,7 @@ 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, @@ -281,20 +281,25 @@ function renderContentBlock( } } -function CopyAction({ text }: { text: string }) { +function CopyAction({ + copied, + onCopy, +}: { + copied: boolean; + onCopy: () => void; +}) { const { t } = useTranslation(["chat", "common"]); - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; return ( {copied ? : } @@ -316,6 +321,7 @@ export const MessageBubble = memo(function MessageBubble({ ? state.getPersonaById(message.metadata.personaId) : undefined, ); + const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard(); const personaAvatarUrl = useAvatarSrc(persona?.avatar); const textContent = content @@ -356,26 +362,31 @@ export const MessageBubble = memo(function MessageBubble({ (assistantDisplayName || personaAvatarUrl || assistantProviderIcon), ); const messageAttachments = message.metadata?.attachments ?? []; + const timestamp = ( + + {formatDate(created, { + hour: "2-digit", + minute: "2-digit", + })} + + ); return (
- {isUser ? ( -
- -
- ) : null} -
{showAssistantIdentity ? ( @@ -406,7 +417,10 @@ export const MessageBubble = memo(function MessageBubble({ {/* biome-ignore lint/a11y/useKeyWithClickEvents: delegated link handler */} {/* biome-ignore lint/a11y/noStaticElementInteractions: delegated link handler */}
{messageAttachments.length > 0 && ( @@ -447,32 +461,51 @@ export const MessageBubble = memo(function MessageBubble({ )}
- {/* Hover actions + timestamp */} - - {textContent && } - {!isUser && onRetryMessage && ( - onRetryMessage(message.id)} - > - - +
onEditMessage(message.id)} - > - - - )} - - {formatDate(created, { - hour: "2-digit", - minute: "2-digit", - })} - - + > + + {isUser && timestamp} + {textContent && ( + copyToClipboard(textContent)} + /> + )} + {!isUser && onRetryMessage && ( + onRetryMessage(message.id)} + > + + + )} + {isUser && onEditMessage && ( + onEditMessage(message.id)} + > + + + )} + {!isUser && timestamp} + +
); 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 51bcf5d5..85cd06b0 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx @@ -1,10 +1,14 @@ -import { beforeEach, describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, 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(), +})); // ── helpers ─────────────────────────────────────────────────────────── @@ -37,6 +41,17 @@ 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", () => { @@ -66,6 +81,18 @@ describe("MessageBubble", () => { expect(screen.getByText("hello world")).toBeInTheDocument(); }); + it("renders user text inside a muted bubble shell", () => { + const { container } = render( + , + ); + + expect( + container.querySelector( + '[data-role="user-message"] .rounded-2xl.bg-muted', + ), + ).toBeInTheDocument(); + }); + it("renders multiple content blocks", () => { const msg = assistantMessage([ { type: "text", text: "first block" }, @@ -76,16 +103,105 @@ describe("MessageBubble", () => { expect(screen.getByText("second block")).toBeInTheDocument(); }); - it("shows action buttons on hover (retry for assistant)", () => { + it("renders a reserved actions tray for assistant messages", () => { const onRetryMessage = vi.fn(); - render( + const { container } = render( , ); - const retryBtn = screen.getByRole("button", { name: /retry/i }); - expect(retryBtn).toBeInTheDocument(); + + 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", () => {