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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { TypingIndicator } from "@loopover/ui-kit/components/typing-indicator";
import { emptyConversation, multiTurnConversation, singleMessage, type ChatMessage } from "./components/chat/fixtures";

describe("MessageList (#6515) — StateBoundary branches", () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/loopover-miner-ui/src/components/chat/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { useCallback, useRef, useState } from "react";
import { Avatar, AvatarFallback } from "@loopover/ui-kit/components/avatar";

import { ChatComposer } from "@/components/chat-composer";
import { StreamingText } from "@/components/streaming-text";
import { StreamingText } from "@loopover/ui-kit/components/streaming-text";
import { MessageList } from "@/components/chat/message-list";
import type { ChatMessage } from "@/components/chat/fixtures";
import type { ChunkSource } from "@/lib/use-streaming-text";
import type { ChunkSource } from "@loopover/ui-kit/hooks/use-streaming-text";
import { streamChat, type ChatWireMessage } from "@/lib/chat-stream";
import {
handlePortfolioQueueChatCommand,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ScrollArea } from "@loopover/ui-kit/components/scroll-area";
import { StateBoundary } from "@loopover/ui-kit/components/state-views";
import { isChatViewportNearBottom, scrollChatViewportToBottom } from "@/lib/chat-scroll";
import { MessageBubble } from "./message-bubble";
import { TypingIndicator } from "./typing-indicator";
import { TypingIndicator } from "@loopover/ui-kit/components/typing-indicator";
import type { ChatMessage } from "./fixtures";

// The scrollable message list for the chat rail (#6515). Backend-agnostic: it renders whatever message
Expand Down
4 changes: 4 additions & 0 deletions packages/loopover-ui-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"types": "./dist/hooks/use-mobile.d.ts",
"default": "./dist/hooks/use-mobile.js"
},
"./hooks/use-streaming-text": {
"types": "./dist/hooks/use-streaming-text.d.ts",
"default": "./dist/hooks/use-streaming-text.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"default": "./dist/components/*.js"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StreamingText } from "./components/streaming-text";
import type { ChunkSource } from "./lib/use-streaming-text";
import { StreamingText } from "./streaming-text";
import type { ChunkSource } from "../hooks/use-streaming-text";

afterEach(() => vi.unstubAllGlobals());

Expand Down Expand Up @@ -39,7 +39,8 @@ function deferredSource() {
}
return {
source: (() => gen()) as ChunkSource,
push: async (chunk: string) => act(async () => (queued.push(chunk), wake())),
push: async (chunk: string) =>
act(async () => (queued.push(chunk), wake())),
finish: async () => act(async () => ((finished = true), wake())),
};
}
Expand All @@ -50,7 +51,9 @@ describe("StreamingText (#6516)", () => {
it("renders an idle paragraph with no text when given no source", () => {
mockReducedMotion(false);
const { container } = render(<StreamingText source={null} />);
expect(container.querySelector("p")?.getAttribute("data-status")).toBe("idle");
expect(container.querySelector("p")?.getAttribute("data-status")).toBe(
"idle",
);
expect(container.textContent).toBe("");
});

Expand All @@ -75,7 +78,11 @@ describe("StreamingText (#6516)", () => {
expect(caret()).toBeNull(); // reduced motion → no animated caret even mid-stream

await src.finish();
await waitFor(() => expect(document.querySelector("p")?.getAttribute("data-status")).toBe("done"));
await waitFor(() =>
expect(document.querySelector("p")?.getAttribute("data-status")).toBe(
"done",
),
);
expect(document.querySelector("p")?.textContent).toContain("no caret here");
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useEffect, useState } from "react";
import { useStreamingText, type ChunkSource } from "@/lib/use-streaming-text";
import {
useStreamingText,
type ChunkSource,
} from "../hooks/use-streaming-text";

// prefers-reduced-motion detection via window.matchMedia + a `change` listener — the same technique
// packages/loopover-ui-kit/src/hooks/use-mobile.tsx uses. Kept internal (not exported) so this file only
Expand All @@ -11,7 +14,11 @@ function usePrefersReducedMotion(): boolean {
: false,
);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
)
return;
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(query.matches);
query.addEventListener("change", onChange);
Expand All @@ -25,14 +32,27 @@ function usePrefersReducedMotion(): boolean {
* streaming, a blinking caret. The reveal itself is never gated — only the caret animation is suppressed under
* prefers-reduced-motion, so reduced-motion users still see the full text arrive, just without the animation.
*/
export function StreamingText({ source, className }: { source: ChunkSource | null; className?: string }) {
export function StreamingText({
source,
className,
}: {
source: ChunkSource | null;
className?: string;
}) {
const { text, status } = useStreamingText(source);
const reducedMotion = usePrefersReducedMotion();
return (
<p className={className} data-status={status} aria-busy={status === "streaming"}>
<p
className={className}
data-status={status}
aria-busy={status === "streaming"}
>
{text}
{status === "streaming" && !reducedMotion ? (
<span aria-hidden="true" className="ml-0.5 inline-block animate-pulse motion-reduce:animate-none">
<span
aria-hidden="true"
className="ml-0.5 inline-block animate-pulse motion-reduce:animate-none"
>
</span>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export function TypingIndicator({
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">
<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"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useStreamingText, type ChunkSource } from "./lib/use-streaming-text";
import { useStreamingText, type ChunkSource } from "./use-streaming-text";

afterEach(() => vi.restoreAllMocks());

Expand Down Expand Up @@ -32,7 +32,8 @@ function deferredSource() {
}
return {
source: (() => gen()) as ChunkSource,
push: async (chunk: string) => act(async () => (queued.push(chunk), wake())),
push: async (chunk: string) =>
act(async () => (queued.push(chunk), wake())),
fail: async (err: Error) => act(async () => ((failure = err), wake())),
finish: async () => act(async () => ((finished = true), wake())),
};
Expand All @@ -41,7 +42,11 @@ function deferredSource() {
describe("useStreamingText (#6516)", () => {
it("starts idle when given no source", () => {
const { result } = renderHook(() => useStreamingText(null));
expect(result.current).toMatchObject({ text: "", status: "idle", error: null });
expect(result.current).toMatchObject({
text: "",
status: "idle",
error: null,
});
});

it("accumulates chunks incrementally across renders, then reaches done", async () => {
Expand Down Expand Up @@ -77,9 +82,12 @@ describe("useStreamingText (#6516)", () => {

it("starting a new source stops the previous one; its late chunk never reaches state", async () => {
const first = deferredSource();
const { result, rerender } = renderHook(({ s }: { s: ChunkSource }) => useStreamingText(s), {
initialProps: { s: first.source },
});
const { result, rerender } = renderHook(
({ s }: { s: ChunkSource }) => useStreamingText(s),
{
initialProps: { s: first.source },
},
);
await first.push("old");
await waitFor(() => expect(result.current.text).toBe("old"));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
*/
export type ChunkSource = () => AsyncIterable<string>;

export type StreamingStatus = "idle" | "streaming" | "done" | "error" | "cancelled";
export type StreamingStatus =
"idle" | "streaming" | "done" | "error" | "cancelled";

export interface StreamingTextState {
/** Text accumulated from all chunks consumed so far. */
Expand All @@ -25,7 +26,9 @@ export interface StreamingTextState {
* a chunk resolving after a new source starts, after `cancel()`, or after unmount never touches state. This is
* an unwired primitive: it only ever consumes the source it's handed (a mock in tests, a real stream later).
*/
export function useStreamingText(source: ChunkSource | null): StreamingTextState {
export function useStreamingText(
source: ChunkSource | null,
): StreamingTextState {
const [text, setText] = useState("");
const [status, setStatus] = useState<StreamingStatus>("idle");
const [error, setError] = useState<Error | null>(null);
Expand Down
Loading