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
17 changes: 17 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,23 @@
background: oklch(0.72 0.14 200 / 0.08);
}

/* ── Agent animations ──────────────────────────────────────────────────────── */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.animate-blink {
animation: blink 1s step-end infinite;
}

@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}

/* Custom scrollbar */
::-webkit-scrollbar {
width: 4px;
Expand Down
23 changes: 19 additions & 4 deletions src/components/agent/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ToolCallRow } from "./tool-call-row"
import { unlockNode } from "@/lib/unlock-node"
import { useSchemaStore } from "@/stores/schema-store"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import type { AgentMessage } from "@/lib/agent-api"
import type { GraphNode } from "@/lib/graph-api"

Expand Down Expand Up @@ -138,16 +139,30 @@ export function MessageList({ messages }: MessageListProps) {
)}

{/* Agent answer */}
{msg.content && (
{(msg.content || msg.isStreaming) && (
<div
className={cn(
"bg-muted/40 border border-border/40 rounded-2xl rounded-tl-sm px-3 py-2.5",
msg.isStreaming && "animate-pulse-subtle"
)}
>
<MarkdownText text={msg.content} />
{msg.isStreaming && (
<span className="inline-block h-3.5 w-0.5 bg-primary ml-0.5 animate-blink" />
{msg.content ? (
<>
<MarkdownText text={msg.content} />
{msg.isStreaming && (
<span className="inline-block h-3.5 w-0.5 bg-primary ml-0.5 animate-blink" />
)}
</>
) : (
/* Thinking placeholder — visible while streaming but no text has arrived yet */
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs text-muted-foreground italic">Thinking…</span>
<div className="flex gap-1 items-center">
<Skeleton className="h-1.5 w-1.5 rounded-full" />
<Skeleton className="h-1.5 w-1.5 rounded-full opacity-70" />
<Skeleton className="h-1.5 w-1.5 rounded-full opacity-40" />
</div>
</div>
)}
</div>
)}
Expand Down
90 changes: 90 additions & 0 deletions src/lib/__tests__/message-list.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import React from "react"

// ── Mock heavy dependencies ───────────────────────────────────────────────────
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

vi.mock("@/components/ui/skeleton", () => ({
Skeleton: ({ className }: { className?: string }) => (
<div data-testid="skeleton" className={className} />
),
}))

vi.mock("@/components/layout/node-row", () => ({
NodeRow: () => <div data-testid="node-row" />,
}))

vi.mock("@/lib/unlock-node", () => ({
unlockNode: vi.fn().mockResolvedValue(undefined),
}))

vi.mock("@/stores/schema-store", () => ({
useSchemaStore: (sel: (s: { schemas: [] }) => unknown) => sel({ schemas: [] }),
}))

import { MessageList } from "@/components/agent/message-list"

describe("MessageList", () => {
it("shows Thinking… placeholder when isStreaming=true and content is empty", () => {
render(
<MessageList
messages={[{ role: "agent", content: "", isStreaming: true }]}
/>
)
expect(screen.getByText("Thinking…")).toBeInTheDocument()
})

it("does NOT show Thinking… when content is present, even while streaming", () => {
render(
<MessageList
messages={[{ role: "agent", content: "Hello", isStreaming: true }]}
/>
)
expect(screen.queryByText("Thinking…")).not.toBeInTheDocument()
expect(screen.getByText("Hello")).toBeInTheDocument()
})

it("renders cited sources after streaming completes", () => {
render(
<MessageList
messages={[
{
role: "agent",
content: "Hello",
isStreaming: false,
citedRefIds: ["abc"],
},
]}
/>
)
expect(screen.getByText("Sources")).toBeInTheDocument()
})

it("does NOT render cited sources while still streaming", () => {
render(
<MessageList
messages={[
{
role: "agent",
content: "Hello",
isStreaming: true,
citedRefIds: ["abc"],
},
]}
/>
)
expect(screen.queryByText("Sources")).not.toBeInTheDocument()
})

it("renders three skeleton dots in the Thinking placeholder", () => {
render(
<MessageList
messages={[{ role: "agent", content: "", isStreaming: true }]}
/>
)
expect(screen.getAllByTestId("skeleton")).toHaveLength(3)
})
})
4 changes: 4 additions & 0 deletions src/lib/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ Object.defineProperty(globalThis, "IntersectionObserver", {
configurable: true,
value: IntersectionObserverStub,
})

// scrollIntoView stub — jsdom doesn't implement it, but message-list.tsx
// calls it via a useEffect on the bottom sentinel ref.
Element.prototype.scrollIntoView = () => {}
Loading