diff --git a/apps/loopover-miner-ui/src/chat-message-components.test.tsx b/apps/loopover-miner-ui/src/chat-message-components.test.tsx
new file mode 100644
index 0000000000..68d05f8552
--- /dev/null
+++ b/apps/loopover-miner-ui/src/chat-message-components.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { MessageList } from "./components/chat/message-list";
+import { MessageBubble } from "./components/chat/message-bubble";
+import { TypingIndicator } from "./components/chat/typing-indicator";
+import { emptyConversation, multiTurnConversation, singleMessage, type ChatMessage } from "./components/chat/fixtures";
+
+describe("MessageList (#6515) — StateBoundary branches", () => {
+ it("renders one bubble per message in the populated state", () => {
+ render();
+ expect(screen.getAllByRole("listitem")).toHaveLength(multiTurnConversation.length);
+ expect(screen.getByText(multiTurnConversation[0]!.content)).toBeTruthy();
+ });
+
+ it("shows the empty state (not a bare list) when there are no messages", () => {
+ render();
+ expect(screen.getByText(/No messages yet/i)).toBeTruthy();
+ expect(screen.queryAllByRole("listitem")).toHaveLength(0);
+ });
+
+ it("shows the loading state", () => {
+ render();
+ expect(screen.getByText(/Loading conversation/i)).toBeTruthy();
+ expect(screen.queryAllByRole("listitem")).toHaveLength(0);
+ });
+
+ it("shows the error state", () => {
+ render();
+ expect(screen.getByText(/Couldn't load the conversation/i)).toBeTruthy();
+ });
+
+ it("renders the typing indicator (below the list) when composing, independent of message state", () => {
+ render();
+ expect(screen.getByRole("status", { name: /is typing/i })).toBeTruthy();
+ // still renders the list itself
+ expect(screen.getAllByRole("listitem")).toHaveLength(1);
+ });
+
+ it("does not render the typing indicator when not composing", () => {
+ render();
+ expect(screen.queryByRole("status", { name: /is typing/i })).toBeNull();
+ });
+});
+
+describe("MessageBubble (#6515) — role-color + avatar branches", () => {
+ const base: ChatMessage = {
+ id: "x",
+ role: "assistant",
+ content: "hello world",
+ timestamp: "2026-07-16T08:00:00.000Z",
+ };
+
+ it("applies a distinct role-colored background per role, from theme tokens only", () => {
+ const roleToClass: Record = {
+ user: "bg-primary",
+ assistant: "bg-muted",
+ system: "bg-secondary",
+ };
+ for (const [role, cls] of Object.entries(roleToClass) as [ChatMessage["role"], string][]) {
+ const { unmount } = render();
+ expect(screen.getByText(`c-${role}`).className).toContain(cls);
+ unmount();
+ }
+ });
+
+ it("renders the initials fallback from the author name (or role when unnamed)", () => {
+ const { unmount } = render();
+ expect(screen.getByText("OP")).toBeTruthy();
+ unmount();
+ render(); // no authorName → falls back to the role
+ expect(screen.getByText("AS")).toBeTruthy();
+ });
+
+ it("renders no
and shows the initials fallback when no avatarUrl is given", () => {
+ const { container } = render();
+ expect(container.querySelector("img")).toBeNull();
+ expect(screen.getByText("AS")).toBeTruthy();
+ });
+
+ it("accepts an avatarUrl without breaking the fallback path", () => {
+ // Radix AvatarImage mounts the real
only after it loads (which never happens in jsdom), so the
+ // initials fallback is the state that renders here — exercising the avatarUrl-present branch safely.
+ render(
+ ,
+ );
+ expect(screen.getByText("OP")).toBeTruthy();
+ });
+
+ it("renders a machine-readable timestamp", () => {
+ render();
+ const time = document.querySelector("time");
+ expect(time?.getAttribute("dateTime")).toBe(base.timestamp);
+ });
+});
+
+describe("TypingIndicator (#6515) — composing branches + accessible name", () => {
+ it("renders a typing-specific accessible name (not 'loading') when composing", () => {
+ render();
+ const status = screen.getByRole("status", { name: /LoopOver is typing/i });
+ expect(status).toBeTruthy();
+ expect(status.getAttribute("aria-label")).toMatch(/typing/i);
+ expect(status.getAttribute("aria-label")).not.toMatch(/loading/i);
+ });
+
+ it("renders nothing when not composing", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/apps/loopover-miner-ui/src/components/chat/fixtures.ts b/apps/loopover-miner-ui/src/components/chat/fixtures.ts
new file mode 100644
index 0000000000..7d00c2f664
--- /dev/null
+++ b/apps/loopover-miner-ui/src/components/chat/fixtures.ts
@@ -0,0 +1,74 @@
+// Fixture data for the miner-ui chat components (#6515). These components are backend-agnostic — driven
+// entirely by props (a message array + a composing flag); a real data source arrives in a later,
+// separately-scoped issue. The shapes below are exactly what MessageBubble / MessageList render against.
+
+export type ChatRole = "user" | "assistant" | "system";
+
+export interface ChatMessage {
+ id: string;
+ role: ChatRole;
+ content: string;
+ /** ISO-8601 timestamp; MessageBubble renders it through a