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 ? ( -

+
{label}