diff --git a/apps/loopover-miner-ui/src/chat-message-components.test.tsx b/apps/loopover-miner-ui/src/chat-message-components.test.tsx
index 46c99a5ac7..330e9048cb 100644
--- a/apps/loopover-miner-ui/src/chat-message-components.test.tsx
+++ b/apps/loopover-miner-ui/src/chat-message-components.test.tsx
@@ -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", () => {
diff --git a/apps/loopover-miner-ui/src/components/chat/conversation.tsx b/apps/loopover-miner-ui/src/components/chat/conversation.tsx
index 2a2489a4fe..57c8357e32 100644
--- a/apps/loopover-miner-ui/src/components/chat/conversation.tsx
+++ b/apps/loopover-miner-ui/src/components/chat/conversation.tsx
@@ -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,
diff --git a/apps/loopover-miner-ui/src/components/chat/message-list.tsx b/apps/loopover-miner-ui/src/components/chat/message-list.tsx
index 94ae1c6051..157b7429fb 100644
--- a/apps/loopover-miner-ui/src/components/chat/message-list.tsx
+++ b/apps/loopover-miner-ui/src/components/chat/message-list.tsx
@@ -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
diff --git a/packages/loopover-ui-kit/package.json b/packages/loopover-ui-kit/package.json
index c0a7eb6196..17455afb80 100644
--- a/packages/loopover-ui-kit/package.json
+++ b/packages/loopover-ui-kit/package.json
@@ -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"
diff --git a/apps/loopover-miner-ui/src/streaming-text.test.tsx b/packages/loopover-ui-kit/src/components/streaming-text.test.tsx
similarity index 88%
rename from apps/loopover-miner-ui/src/streaming-text.test.tsx
rename to packages/loopover-ui-kit/src/components/streaming-text.test.tsx
index 654d5960ec..2cf17ac12b 100644
--- a/apps/loopover-miner-ui/src/streaming-text.test.tsx
+++ b/packages/loopover-ui-kit/src/components/streaming-text.test.tsx
@@ -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());
@@ -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())),
};
}
@@ -50,7 +51,9 @@ describe("StreamingText (#6516)", () => {
it("renders an idle paragraph with no text when given no source", () => {
mockReducedMotion(false);
const { container } = render();
- expect(container.querySelector("p")?.getAttribute("data-status")).toBe("idle");
+ expect(container.querySelector("p")?.getAttribute("data-status")).toBe(
+ "idle",
+ );
expect(container.textContent).toBe("");
});
@@ -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");
});
});
diff --git a/apps/loopover-miner-ui/src/components/streaming-text.tsx b/packages/loopover-ui-kit/src/components/streaming-text.tsx
similarity index 72%
rename from apps/loopover-miner-ui/src/components/streaming-text.tsx
rename to packages/loopover-ui-kit/src/components/streaming-text.tsx
index 4af577184e..ed03fe5535 100644
--- a/apps/loopover-miner-ui/src/components/streaming-text.tsx
+++ b/packages/loopover-ui-kit/src/components/streaming-text.tsx
@@ -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
@@ -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);
@@ -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 (
-
+
{text}
{status === "streaming" && !reducedMotion ? (
-
+
▍
) : null}
diff --git a/apps/loopover-miner-ui/src/components/chat/typing-indicator.test.tsx b/packages/loopover-ui-kit/src/components/typing-indicator.test.tsx
similarity index 100%
rename from apps/loopover-miner-ui/src/components/chat/typing-indicator.test.tsx
rename to packages/loopover-ui-kit/src/components/typing-indicator.test.tsx
diff --git a/apps/loopover-miner-ui/src/components/chat/typing-indicator.tsx b/packages/loopover-ui-kit/src/components/typing-indicator.tsx
similarity index 89%
rename from apps/loopover-miner-ui/src/components/chat/typing-indicator.tsx
rename to packages/loopover-ui-kit/src/components/typing-indicator.tsx
index 5cecec1b09..7a0dc0fe2f 100644
--- a/apps/loopover-miner-ui/src/components/chat/typing-indicator.tsx
+++ b/packages/loopover-ui-kit/src/components/typing-indicator.tsx
@@ -12,7 +12,12 @@ export function TypingIndicator({
if (!composing) return null;
const label = `${authorName ?? "Assistant"} is typing…`;
return (
-
+
{label}
vi.restoreAllMocks());
@@ -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())),
};
@@ -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 () => {
@@ -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"));
diff --git a/apps/loopover-miner-ui/src/lib/use-streaming-text.ts b/packages/loopover-ui-kit/src/hooks/use-streaming-text.ts
similarity index 94%
rename from apps/loopover-miner-ui/src/lib/use-streaming-text.ts
rename to packages/loopover-ui-kit/src/hooks/use-streaming-text.ts
index 81c8ddaf1f..03c3923517 100644
--- a/apps/loopover-miner-ui/src/lib/use-streaming-text.ts
+++ b/packages/loopover-ui-kit/src/hooks/use-streaming-text.ts
@@ -8,7 +8,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
*/
export type ChunkSource = () => AsyncIterable;
-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. */
@@ -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("idle");
const [error, setError] = useState(null);