diff --git a/src/app/globals.css b/src/app/globals.css
index ea35eb1..5650600 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
diff --git a/src/components/agent/message-list.tsx b/src/components/agent/message-list.tsx
index 72877c2..d95c9cd 100644
--- a/src/components/agent/message-list.tsx
+++ b/src/components/agent/message-list.tsx
@@ -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"
@@ -138,16 +139,30 @@ export function MessageList({ messages }: MessageListProps) {
)}
{/* Agent answer */}
- {msg.content && (
+ {(msg.content || msg.isStreaming) && (
-
- {msg.isStreaming && (
-
+ {msg.content ? (
+ <>
+
+ {msg.isStreaming && (
+
+ )}
+ >
+ ) : (
+ /* Thinking placeholder — visible while streaming but no text has arrived yet */
+
)}
)}
diff --git a/src/lib/__tests__/message-list.test.tsx b/src/lib/__tests__/message-list.test.tsx
new file mode 100644
index 0000000..aa1b41a
--- /dev/null
+++ b/src/lib/__tests__/message-list.test.tsx
@@ -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 }) => {children}
,
+}))
+
+vi.mock("@/components/ui/skeleton", () => ({
+ Skeleton: ({ className }: { className?: string }) => (
+
+ ),
+}))
+
+vi.mock("@/components/layout/node-row", () => ({
+ NodeRow: () => ,
+}))
+
+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(
+
+ )
+ expect(screen.getByText("Thinking…")).toBeInTheDocument()
+ })
+
+ it("does NOT show Thinking… when content is present, even while streaming", () => {
+ render(
+
+ )
+ expect(screen.queryByText("Thinking…")).not.toBeInTheDocument()
+ expect(screen.getByText("Hello")).toBeInTheDocument()
+ })
+
+ it("renders cited sources after streaming completes", () => {
+ render(
+
+ )
+ expect(screen.getByText("Sources")).toBeInTheDocument()
+ })
+
+ it("does NOT render cited sources while still streaming", () => {
+ render(
+
+ )
+ expect(screen.queryByText("Sources")).not.toBeInTheDocument()
+ })
+
+ it("renders three skeleton dots in the Thinking placeholder", () => {
+ render(
+
+ )
+ expect(screen.getAllByTestId("skeleton")).toHaveLength(3)
+ })
+})
diff --git a/src/lib/__tests__/setup.ts b/src/lib/__tests__/setup.ts
index af427ff..4e43058 100644
--- a/src/lib/__tests__/setup.ts
+++ b/src/lib/__tests__/setup.ts
@@ -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 = () => {}