Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions apps/loopover-miner-ui/src/chat-message-components.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MessageList messages={multiTurnConversation} />);
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(<MessageList messages={emptyConversation} />);
expect(screen.getByText(/No messages yet/i)).toBeTruthy();
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});

it("shows the loading state", () => {
render(<MessageList messages={emptyConversation} isLoading />);
expect(screen.getByText(/Loading conversation/i)).toBeTruthy();
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});

it("shows the error state", () => {
render(<MessageList messages={emptyConversation} isError />);
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(<MessageList messages={singleMessage} composing />);
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(<MessageList messages={singleMessage} />);
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<ChatMessage["role"], string> = {
user: "bg-primary",
assistant: "bg-muted",
system: "bg-secondary",
};
for (const [role, cls] of Object.entries(roleToClass) as [ChatMessage["role"], string][]) {
const { unmount } = render(<MessageBubble message={{ ...base, role, content: `c-${role}` }} />);
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(<MessageBubble message={{ ...base, authorName: "operator" }} />);
expect(screen.getByText("OP")).toBeTruthy();
unmount();
render(<MessageBubble message={base} />); // no authorName → falls back to the role
expect(screen.getByText("AS")).toBeTruthy();
});

it("renders no <img> and shows the initials fallback when no avatarUrl is given", () => {
const { container } = render(<MessageBubble message={base} />);
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("AS")).toBeTruthy();
});

it("accepts an avatarUrl without breaking the fallback path", () => {
// Radix AvatarImage mounts the real <img> 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(
<MessageBubble message={{ ...base, role: "user", authorName: "operator", avatarUrl: "https://a.test/o.png" }} />,
);
expect(screen.getByText("OP")).toBeTruthy();
});

it("renders a machine-readable timestamp", () => {
render(<MessageBubble message={base} />);
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(<TypingIndicator composing authorName="LoopOver" />);
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(<TypingIndicator composing={false} />);
expect(container.firstChild).toBeNull();
});
});
74 changes: 74 additions & 0 deletions apps/loopover-miner-ui/src/components/chat/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 <time dateTime={…}> element. */
timestamp: string;
/** Optional display name; falls back to the role for the avatar initials and image alt text. */
authorName?: string;
/** Optional avatar image URL; when absent, MessageBubble renders the initials fallback only. */
avatarUrl?: string;
}

/** Empty conversation — drives MessageList's StateBoundary empty branch. */
export const emptyConversation: ChatMessage[] = [];

export const singleMessage: ChatMessage[] = [
{
id: "m1",
role: "assistant",
content: "Hi — ask me anything about this miner's queue, runs, or ledgers.",
timestamp: "2026-07-16T08:00:00.000Z",
authorName: "LoopOver",
},
];

/** A representative multi-turn exchange spanning all three roles, with and without an avatar image. */
export const multiTurnConversation: ChatMessage[] = [
{
id: "m1",
role: "user",
content: "What's stuck in the portfolio queue?",
timestamp: "2026-07-16T08:00:10.000Z",
authorName: "operator",
avatarUrl: "https://avatars.example.test/operator.png",
},
{
id: "m2",
role: "assistant",
content: "Two items are leased but idle past their TTL. Want me to reclaim them?",
timestamp: "2026-07-16T08:00:15.000Z",
authorName: "LoopOver",
},
{
id: "m3",
role: "user",
content: "Yes, reclaim both.",
timestamp: "2026-07-16T08:00:20.000Z",
authorName: "operator",
avatarUrl: "https://avatars.example.test/operator.png",
},
{
id: "m4",
role: "system",
content: "Reclaimed 2 stuck items back to queued.",
timestamp: "2026-07-16T08:00:21.000Z",
},
];

/** A long-content edge case — exercises wrapping/overflow in MessageBubble and MessageList's viewport. */
export const longContentConversation: ChatMessage[] = [
{
id: "m1",
role: "assistant",
content: `Full run breakdown follows. ${"token ".repeat(120)}`.trim(),
timestamp: "2026-07-16T08:05:00.000Z",
authorName: "LoopOver",
},
];
44 changes: 44 additions & 0 deletions apps/loopover-miner-ui/src/components/chat/message-bubble.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Avatar, AvatarFallback, AvatarImage } from "@loopover/ui-kit/components/avatar";
import type { ChatMessage, ChatRole } from "./fixtures";

// Role-differentiated bubble backgrounds, built ONLY from existing @loopover/ui-kit theme tokens
// (packages/loopover-ui-kit/src/theme.css) — no new color literals: user = primary, assistant = muted
// surface, system = secondary.
const ROLE_BUBBLE_CLASS: Record<ChatRole, string> = {
user: "bg-primary text-primary-foreground",
assistant: "bg-muted text-foreground",
system: "bg-secondary text-secondary-foreground",
};

function avatarInitials(message: ChatMessage): string {
const source = (message.authorName ?? message.role).trim();
return (source.slice(0, 2) || message.role.slice(0, 2)).toUpperCase();
}

function formatTimestamp(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime())
? iso
: date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}

export function MessageBubble({ message }: { message: ChatMessage }) {
const bubbleClass = ROLE_BUBBLE_CLASS[message.role];
const displayName = message.authorName ?? message.role;
return (
<div className="flex gap-3">
<Avatar className="size-8 shrink-0">
{message.avatarUrl ? <AvatarImage src={message.avatarUrl} alt={displayName} /> : null}
<AvatarFallback>{avatarInitials(message)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-1">
<div className={`whitespace-pre-wrap break-words rounded-token-sm px-3 py-2 text-token-sm ${bubbleClass}`}>
{message.content}
</div>
<time className="text-token-xs text-muted-foreground" dateTime={message.timestamp}>
{formatTimestamp(message.timestamp)}
</time>
</div>
</div>
);
}
45 changes: 45 additions & 0 deletions apps/loopover-miner-ui/src/components/chat/message-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ScrollArea } from "@loopover/ui-kit/components/scroll-area";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { MessageBubble } from "./message-bubble";
import { TypingIndicator } from "./typing-indicator";
import type { ChatMessage } from "./fixtures";

// The scrollable message list for the chat rail (#6515). Backend-agnostic: it renders whatever message
// array it's given, wrapping the content in ui-kit's StateBoundary for its own loading/empty/error states
// and using ui-kit's ScrollArea (not a raw overflow div) for the viewport. The composing flag surfaces the
// TypingIndicator below the list regardless of the message-array state.
export function MessageList({
messages,
isLoading = false,
isError = false,
composing = false,
}: {
messages: ChatMessage[];
isLoading?: boolean;
isError?: boolean;
composing?: boolean;
}) {
return (
<ScrollArea className="h-full">
<StateBoundary
isLoading={isLoading}
isError={isError}
isEmpty={messages.length === 0}
loadingTitle="Loading conversation…"
emptyTitle="No messages yet"
emptyDescription="Start the conversation to see messages here."
errorTitle="Couldn't load the conversation"
errorDescription="The conversation source did not respond. Retry, or check back once it has recovered."
>
<ol className="flex flex-col gap-4 p-3">
{messages.map((message) => (
<li key={message.id}>
<MessageBubble message={message} />
</li>
))}
</ol>
</StateBoundary>
{composing ? <TypingIndicator composing authorName="Assistant" /> : null}
</ScrollArea>
);
}
28 changes: 28 additions & 0 deletions apps/loopover-miner-ui/src/components/chat/typing-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// A composing / "typing" affordance for the chat (#6515) — deliberately distinct from @loopover/ui-kit's
// Spinner, which only signals whole-panel LOADING. This is an animated three-dot pulse with a typing-specific
// accessible name, so assistive tech hears "…is typing", not "loading".
export function TypingIndicator({
composing = true,
authorName,
}: {
/** When false, renders nothing — the other side isn't composing. */
composing?: boolean;
authorName?: string;
}) {
if (!composing) return null;
const label = `${authorName ?? "Assistant"} is typing…`;
return (
<div role="status" aria-live="polite" aria-label={label} className="flex items-center gap-1.5 px-3 py-2">
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.3s]"
/>
<span
aria-hidden="true"
className="size-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.15s]"
/>
<span aria-hidden="true" className="size-1.5 animate-bounce rounded-full bg-muted-foreground" />
</div>
);
}
Loading