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", () => {