Skip to content

Commit 8f89010

Browse files
tellahospikewang
authored andcommitted
feat: goose2 message bubble + action tray (aaif-goose#8720)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
1 parent ce4aac4 commit 8f89010

3 files changed

Lines changed: 206 additions & 52 deletions

File tree

ui/goose2/scripts/check-file-sizes.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ const EXCEPTIONS = {
6565
justification:
6666
"Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.",
6767
},
68+
"src/features/chat/ui/MessageBubble.tsx": {
69+
limit: 520,
70+
justification:
71+
"Bubble rendering still owns assistant identity, grouped tool output, attachments, and the inline actions tray pending a later extraction pass.",
72+
},
6873
"src/features/chat/ui/__tests__/ChatInput.test.tsx": {
6974
limit: 520,
7075
justification:

ui/goose2/src/features/chat/ui/MessageBubble.tsx

Lines changed: 79 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { useState, memo } from "react";
1+
import { memo } from "react";
22
import { useTranslation } from "react-i18next";
33
import {
44
Copy,
55
Check,
66
RotateCcw,
77
Pencil,
8-
User,
98
FileText,
109
FolderClosed,
1110
} from "lucide-react";
@@ -14,6 +13,7 @@ import { openPath } from "@tauri-apps/plugin-opener";
1413
import { cn } from "@/shared/lib/cn";
1514
import { useLocaleFormatting } from "@/shared/i18n";
1615
import { useAgentStore } from "@/features/agents/stores/agentStore";
16+
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
1717
import { getCatalogEntry } from "@/features/providers/providerCatalog";
1818
import {
1919
getProviderIcon,
@@ -281,20 +281,25 @@ function renderContentBlock(
281281
}
282282
}
283283

284-
function CopyAction({ text }: { text: string }) {
284+
function CopyAction({
285+
copied,
286+
onCopy,
287+
}: {
288+
copied: boolean;
289+
onCopy: () => void;
290+
}) {
285291
const { t } = useTranslation(["chat", "common"]);
286-
const [copied, setCopied] = useState(false);
287-
288-
const handleCopy = () => {
289-
navigator.clipboard.writeText(text);
290-
setCopied(true);
291-
setTimeout(() => setCopied(false), 2000);
292-
};
293292

294293
return (
295294
<MessageAction
295+
size="xs"
296+
variant="ghost-light"
297+
className={cn(
298+
"text-muted-foreground",
299+
copied && "bg-accent text-foreground hover:bg-accent active:bg-accent",
300+
)}
296301
tooltip={copied ? t("message.copied") : t("common:actions.copy")}
297-
onClick={handleCopy}
302+
onClick={onCopy}
298303
>
299304
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
300305
</MessageAction>
@@ -316,6 +321,7 @@ export const MessageBubble = memo(function MessageBubble({
316321
? state.getPersonaById(message.metadata.personaId)
317322
: undefined,
318323
);
324+
const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard();
319325
const personaAvatarUrl = useAvatarSrc(persona?.avatar);
320326

321327
const textContent = content
@@ -356,26 +362,31 @@ export const MessageBubble = memo(function MessageBubble({
356362
(assistantDisplayName || personaAvatarUrl || assistantProviderIcon),
357363
);
358364
const messageAttachments = message.metadata?.attachments ?? [];
365+
const timestamp = (
366+
<span
367+
data-role="message-timestamp"
368+
className="shrink-0 whitespace-nowrap px-1 text-[10px] text-muted-foreground"
369+
>
370+
{formatDate(created, {
371+
hour: "2-digit",
372+
minute: "2-digit",
373+
})}
374+
</span>
375+
);
359376

360377
return (
361378
<div
362379
className={cn(
363-
"group flex px-4 py-1",
380+
"flex px-4 py-1",
364381
"animate-in fade-in duration-200 motion-reduce:animate-none",
365382
isUser ? "ml-auto flex-row-reverse gap-3" : "flex-row",
366383
)}
367384
data-role={isUser ? "user-message" : "assistant-message"}
368385
>
369-
{isUser ? (
370-
<div className="flex h-7 w-7 shrink-0 self-start -mt-1 items-center justify-center rounded-full bg-accent">
371-
<User size={14} className="text-muted-foreground" />
372-
</div>
373-
) : null}
374-
375386
<div
376387
className={cn(
377-
"min-w-0 flex flex-col gap-1",
378-
isUser ? "max-w-[80%] items-end" : "max-w-[85%] items-start",
388+
"group relative min-w-0 flex flex-col gap-1 pb-8",
389+
isUser ? "max-w-[640px] items-end" : "max-w-[85%] items-start",
379390
)}
380391
>
381392
{showAssistantIdentity ? (
@@ -406,7 +417,10 @@ export const MessageBubble = memo(function MessageBubble({
406417
{/* biome-ignore lint/a11y/useKeyWithClickEvents: delegated link handler */}
407418
{/* biome-ignore lint/a11y/noStaticElementInteractions: delegated link handler */}
408419
<div
409-
className="w-full min-w-0 text-[13px] leading-relaxed"
420+
className={cn(
421+
"w-full min-w-0 text-[13px] leading-relaxed",
422+
isUser && "rounded-2xl bg-muted p-3",
423+
)}
410424
onClick={handleContentClick}
411425
>
412426
{messageAttachments.length > 0 && (
@@ -447,32 +461,51 @@ export const MessageBubble = memo(function MessageBubble({
447461
)}
448462
</div>
449463

450-
{/* Hover actions + timestamp */}
451-
<MessageActions className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
452-
{textContent && <CopyAction text={textContent} />}
453-
{!isUser && onRetryMessage && (
454-
<MessageAction
455-
tooltip={t("common:actions.retry")}
456-
onClick={() => onRetryMessage(message.id)}
457-
>
458-
<RotateCcw className="size-3.5" />
459-
</MessageAction>
460-
)}
461-
{isUser && onEditMessage && (
462-
<MessageAction
463-
tooltip={t("common:actions.edit")}
464-
onClick={() => onEditMessage(message.id)}
465-
>
466-
<Pencil className="size-3.5" />
467-
</MessageAction>
464+
<div
465+
data-role="message-actions"
466+
data-copy-confirmed={isCopyConfirmed ? "true" : "false"}
467+
className={cn(
468+
"absolute bottom-0 transition-opacity duration-150 ease-out",
469+
"opacity-0 pointer-events-none",
470+
"group-hover:animate-in group-hover:slide-in-from-top-2 group-hover:opacity-100 group-hover:pointer-events-auto",
471+
"group-focus-within:animate-in group-focus-within:slide-in-from-top-2 group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
472+
isCopyConfirmed && "opacity-100 pointer-events-auto",
473+
isUser ? "right-0" : "left-0",
468474
)}
469-
<span className="px-1 text-[10px] text-muted-foreground">
470-
{formatDate(created, {
471-
hour: "2-digit",
472-
minute: "2-digit",
473-
})}
474-
</span>
475-
</MessageActions>
475+
>
476+
<MessageActions className="pt-0">
477+
{isUser && timestamp}
478+
{textContent && (
479+
<CopyAction
480+
copied={isCopyConfirmed}
481+
onCopy={() => copyToClipboard(textContent)}
482+
/>
483+
)}
484+
{!isUser && onRetryMessage && (
485+
<MessageAction
486+
size="xs"
487+
variant="ghost-light"
488+
className="text-muted-foreground"
489+
tooltip={t("common:actions.retry")}
490+
onClick={() => onRetryMessage(message.id)}
491+
>
492+
<RotateCcw className="size-3.5" />
493+
</MessageAction>
494+
)}
495+
{isUser && onEditMessage && (
496+
<MessageAction
497+
size="xs"
498+
variant="ghost-light"
499+
className="text-muted-foreground"
500+
tooltip={t("common:actions.edit")}
501+
onClick={() => onEditMessage(message.id)}
502+
>
503+
<Pencil className="size-3.5" />
504+
</MessageAction>
505+
)}
506+
{!isUser && timestamp}
507+
</MessageActions>
508+
</div>
476509
</div>
477510
</div>
478511
);

ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { beforeEach, describe, it, expect, vi } from "vitest";
2-
import { render, screen } from "@testing-library/react";
1+
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
2+
import { act, fireEvent, render, screen } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import { MessageBubble } from "../MessageBubble";
55
import { useAgentStore } from "@/features/agents/stores/agentStore";
66
import type { Message } from "@/shared/types/messages";
77
import { openPath } from "@tauri-apps/plugin-opener";
8+
const mockWriteText = vi.fn().mockResolvedValue(undefined);
9+
vi.mock("@tauri-apps/plugin-opener", () => ({
10+
openPath: vi.fn(),
11+
}));
812

913
// ── helpers ───────────────────────────────────────────────────────────
1014

@@ -37,6 +41,17 @@ describe("MessageBubble", () => {
3741
beforeEach(() => {
3842
useAgentStore.setState({ personas: [] });
3943
vi.mocked(openPath).mockClear();
44+
mockWriteText.mockClear();
45+
Object.defineProperty(navigator, "clipboard", {
46+
configurable: true,
47+
value: {
48+
writeText: mockWriteText,
49+
},
50+
});
51+
});
52+
53+
afterEach(() => {
54+
vi.useRealTimers();
4055
});
4156

4257
it("renders user message with correct alignment", () => {
@@ -66,6 +81,18 @@ describe("MessageBubble", () => {
6681
expect(screen.getByText("hello world")).toBeInTheDocument();
6782
});
6883

84+
it("renders user text inside a muted bubble shell", () => {
85+
const { container } = render(
86+
<MessageBubble message={userMessage("hello world")} />,
87+
);
88+
89+
expect(
90+
container.querySelector(
91+
'[data-role="user-message"] .rounded-2xl.bg-muted',
92+
),
93+
).toBeInTheDocument();
94+
});
95+
6996
it("renders multiple content blocks", () => {
7097
const msg = assistantMessage([
7198
{ type: "text", text: "first block" },
@@ -76,16 +103,105 @@ describe("MessageBubble", () => {
76103
expect(screen.getByText("second block")).toBeInTheDocument();
77104
});
78105

79-
it("shows action buttons on hover (retry for assistant)", () => {
106+
it("renders a reserved actions tray for assistant messages", () => {
80107
const onRetryMessage = vi.fn();
81-
render(
108+
const { container } = render(
82109
<MessageBubble
83110
message={assistantMessage([{ type: "text", text: "response" }])}
84111
onRetryMessage={onRetryMessage}
85112
/>,
86113
);
87-
const retryBtn = screen.getByRole("button", { name: /retry/i });
88-
expect(retryBtn).toBeInTheDocument();
114+
115+
expect(
116+
container.querySelector('[data-role="assistant-message"] .pb-8'),
117+
).toBeInTheDocument();
118+
expect(
119+
container.querySelector(
120+
'[data-role="assistant-message"] [data-role="message-actions"]',
121+
),
122+
).toBeInTheDocument();
123+
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
124+
});
125+
126+
it("keeps the action tray timestamp on one line", () => {
127+
const { container } = render(
128+
<MessageBubble
129+
message={assistantMessage([{ type: "text", text: "response" }])}
130+
/>,
131+
);
132+
133+
const timestamp = container.querySelector(
134+
'[data-role="assistant-message"] [data-role="message-timestamp"]',
135+
);
136+
expect(timestamp).toHaveClass("whitespace-nowrap");
137+
expect(timestamp).toHaveClass("shrink-0");
138+
});
139+
140+
it("anchors assistant and user actions on opposite sides of the timestamp", () => {
141+
const { container } = render(
142+
<>
143+
<MessageBubble
144+
message={assistantMessage([{ type: "text", text: "response" }])}
145+
onRetryMessage={vi.fn()}
146+
/>
147+
<MessageBubble message={userMessage("draft")} onEditMessage={vi.fn()} />
148+
</>,
149+
);
150+
151+
const assistantActions = container.querySelector(
152+
'[data-role="assistant-message"] [data-role="message-actions"]',
153+
);
154+
const userActions = container.querySelector(
155+
'[data-role="user-message"] [data-role="message-actions"]',
156+
);
157+
158+
expect(
159+
Array.from(assistantActions?.firstElementChild?.children ?? []).map(
160+
(element) => element.tagName,
161+
),
162+
).toEqual(["BUTTON", "BUTTON", "SPAN"]);
163+
expect(
164+
Array.from(userActions?.firstElementChild?.children ?? []).map(
165+
(element) => element.tagName,
166+
),
167+
).toEqual(["SPAN", "BUTTON", "BUTTON"]);
168+
});
169+
170+
it("keeps copy confirmation visible until it resets", async () => {
171+
vi.useFakeTimers();
172+
const { container } = render(
173+
<MessageBubble
174+
message={assistantMessage([{ type: "text", text: "response" }])}
175+
/>,
176+
);
177+
178+
const actions = container.querySelector(
179+
'[data-role="assistant-message"] [data-role="message-actions"]',
180+
);
181+
expect(actions).toHaveAttribute("data-copy-confirmed", "false");
182+
const copyButton = screen.getByRole("button", { name: /copy/i });
183+
expect(copyButton).not.toHaveClass("bg-accent");
184+
185+
await act(async () => {
186+
fireEvent.click(copyButton);
187+
await Promise.resolve();
188+
});
189+
190+
expect(mockWriteText).toHaveBeenCalledWith("response");
191+
expect(actions).toHaveAttribute("data-copy-confirmed", "true");
192+
expect(copyButton).toHaveClass("bg-accent");
193+
194+
await act(async () => {
195+
vi.advanceTimersByTime(1999);
196+
});
197+
expect(actions).toHaveAttribute("data-copy-confirmed", "true");
198+
expect(copyButton).toHaveClass("bg-accent");
199+
200+
await act(async () => {
201+
vi.advanceTimersByTime(1);
202+
});
203+
expect(actions).toHaveAttribute("data-copy-confirmed", "false");
204+
expect(copyButton).not.toHaveClass("bg-accent");
89205
});
90206

91207
it("renders tool request content as ToolCallCard", () => {

0 commit comments

Comments
 (0)