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 */ +
+ Thinking… +
+ + + +
+
)}
)} 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 = () => {}