feat: goose2 message bubble + action tray (#8720)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-04-21 09:22:41 -07:00
committed by GitHub
parent dfc5b0b803
commit 8f73ef9f6c
3 changed files with 206 additions and 52 deletions
+5
View File
@@ -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:
@@ -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 (
<MessageAction
size="xs"
variant="ghost-light"
className={cn(
"text-muted-foreground",
copied && "bg-accent text-foreground hover:bg-accent active:bg-accent",
)}
tooltip={copied ? t("message.copied") : t("common:actions.copy")}
onClick={handleCopy}
onClick={onCopy}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</MessageAction>
@@ -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 = (
<span
data-role="message-timestamp"
className="shrink-0 whitespace-nowrap px-1 text-[10px] text-muted-foreground"
>
{formatDate(created, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
);
return (
<div
className={cn(
"group flex px-4 py-1",
"flex px-4 py-1",
"animate-in fade-in duration-200 motion-reduce:animate-none",
isUser ? "ml-auto flex-row-reverse gap-3" : "flex-row",
)}
data-role={isUser ? "user-message" : "assistant-message"}
>
{isUser ? (
<div className="flex h-7 w-7 shrink-0 self-start -mt-1 items-center justify-center rounded-full bg-accent">
<User size={14} className="text-muted-foreground" />
</div>
) : null}
<div
className={cn(
"min-w-0 flex flex-col gap-1",
isUser ? "max-w-[80%] items-end" : "max-w-[85%] items-start",
"group relative min-w-0 flex flex-col gap-1 pb-8",
isUser ? "max-w-[640px] items-end" : "max-w-[85%] items-start",
)}
>
{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 */}
<div
className="w-full min-w-0 text-[13px] leading-relaxed"
className={cn(
"w-full min-w-0 text-[13px] leading-relaxed",
isUser && "rounded-2xl bg-muted p-3",
)}
onClick={handleContentClick}
>
{messageAttachments.length > 0 && (
@@ -447,32 +461,51 @@ export const MessageBubble = memo(function MessageBubble({
)}
</div>
{/* Hover actions + timestamp */}
<MessageActions className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
{textContent && <CopyAction text={textContent} />}
{!isUser && onRetryMessage && (
<MessageAction
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
<div
data-role="message-actions"
data-copy-confirmed={isCopyConfirmed ? "true" : "false"}
className={cn(
"absolute bottom-0 transition-opacity duration-150 ease-out",
"opacity-0 pointer-events-none",
"group-hover:animate-in group-hover:slide-in-from-top-2 group-hover:opacity-100 group-hover:pointer-events-auto",
"group-focus-within:animate-in group-focus-within:slide-in-from-top-2 group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
isCopyConfirmed && "opacity-100 pointer-events-auto",
isUser ? "right-0" : "left-0",
)}
{isUser && onEditMessage && (
<MessageAction
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
<span className="px-1 text-[10px] text-muted-foreground">
{formatDate(created, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</MessageActions>
>
<MessageActions className="pt-0">
{isUser && timestamp}
{textContent && (
<CopyAction
copied={isCopyConfirmed}
onCopy={() => copyToClipboard(textContent)}
/>
)}
{!isUser && onRetryMessage && (
<MessageAction
size="xs"
variant="ghost-light"
className="text-muted-foreground"
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && (
<MessageAction
size="xs"
variant="ghost-light"
className="text-muted-foreground"
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
{!isUser && timestamp}
</MessageActions>
</div>
</div>
</div>
);
@@ -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(
<MessageBubble message={userMessage("hello world")} />,
);
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(
<MessageBubble
message={assistantMessage([{ type: "text", text: "response" }])}
onRetryMessage={onRetryMessage}
/>,
);
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(
<MessageBubble
message={assistantMessage([{ type: "text", text: "response" }])}
/>,
);
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(
<>
<MessageBubble
message={assistantMessage([{ type: "text", text: "response" }])}
onRetryMessage={vi.fn()}
/>
<MessageBubble message={userMessage("draft")} onEditMessage={vi.fn()} />
</>,
);
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(
<MessageBubble
message={assistantMessage([{ type: "text", text: "response" }])}
/>,
);
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", () => {