feat: goose2 message bubble + action tray (#8720)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user