Skip to content

Chat UI: streaming text renderer / useStreamingText hook #6516

Description

@JSONbored

Context

The miner dashboard redesign adds a persistent, collapsible chat rail to apps/loopover-miner-ui (mounted once in routes/__root.tsx, surviving route navigation — see the persistent-chat-rail-shell issue for that scaffolding). This issue is one narrow, self-contained slice of that work: the primitive that reveals a chat response's text progressively as chunks arrive, instead of popping the whole message in at once.

apps/loopover-miner-ui has no chat surface and no streaming consumer today. The closest thing in the whole monorepo is apps/loopover-ui/src/components/site/animated-terminal.tsx (the main site, a different app) — audited in #6244 (merged via PR #6474, apps/loopover-ui/src/chat-ui-primitives-audit.md) as "the closest existing 'reveal text incrementally' precedent": a setInterval-driven character-by-character typewriter over a hardcoded scene.prompt string, a motion.pre fade-in for scene.output via AnimatePresence, and a useReducedMotion() escape hatch that skips the animation outright (animated-terminal.tsx:68-84, TYPE_SPEED/HOLD consts at lines 43-44). The audit is explicit that this is reveal-mechanics precedent only: "there is no chunked input, no cancellation on new input arriving mid-type, and no backpressure handling" (chat-ui-primitives-audit.md:50-51). It also confirms — via a grep of apps/loopover-ui/src for EventSource|ReadableStream|text/event-stream|streaming — that the only other hit in the whole app is a comment in apps/loopover-ui/src/lib/analytics-proxy.ts (line 90: "Buffer the (tiny) collect payload so we don't need a streaming/duplex body") explaining why that proxy avoids streaming. There is no real streaming consumer anywhere in either UI app yet. This issue makes apps/loopover-miner-ui the first one, but only as a tested, unwired primitive — it is explicitly not yet connected to a real backend.

apps/loopover-miner-ui already has one hook of exactly this shape to model the house convention on: apps/loopover-miner-ui/src/lib/use-polled-fetch.ts exports usePolledFetch, tracks an in-effect cancelled flag so a resolve-after-unmount/after-restart never touches state (use-polled-fetch.ts:16,24,34-36), and its co-located test file apps/loopover-miner-ui/src/use-polled-fetch.test.ts sits flat at the top of src/ (not nested under lib/), uses vi.useFakeTimers(), and includes an explicit "does not update the result after unmount, even if an in-flight fetch resolves late" regression test (lines 76-89). useStreamingText should follow this same shape and the same discipline, just with a chunked source instead of a poll interval.

Two things specific to apps/loopover-miner-ui matter for how this gets built:

  • No motion dependency here. apps/loopover-ui/package.json:68 depends on "motion": "^12.42.2" (what animated-terminal.tsx uses for AnimatePresence/useReducedMotion); apps/loopover-miner-ui/package.json has no such dependency. The existing reduced-motion-detection precedent that is already available to this app is packages/loopover-ui-kit/src/hooks/use-mobile.tsx's useIsMobile, which drives a boolean off window.matchMedia(...) with a change listener (lines 9-15) — the same technique works for prefers-reduced-motion.
  • Codecov doesn't gate this app. codecov.yml's ignore: list includes "apps/**" — this PR's files are not measured by the 99% patch gate. The operative local bar is apps/loopover-miner-ui/vitest.config.ts's coverage thresholds (statements: 85, branches: 85, functions: 75, lines: 85), enforced by npm run ui:testnpm --workspace @loopover/ui-miner run testvitest run --coverage.

This is deliberately scoped narrower than the chat rail itself: no composer, no message list, no wiring into __root.tsx, no real backend call. Those are separate, later issues (the persistent-chat-rail-shell issue, the message-list issue — which per the design synthesis has its own hard prerequisite of porting apps/loopover-ui/src/components/site/state-views.tsx's StateBoundary family into @loopover/ui-kit first — and the chat-backend/grounding issue). This issue is also unrelated to #6230 (the separate, still-open maintainer-chat spec, which lives in apps/loopover-ui) — that scope is explicitly excluded from the miner dashboard redesign this issue belongs to.

⚠️ Read this before starting. This hook and its tests belong entirely in apps/loopover-miner-ui, not apps/loopover-ui. animated-terminal.tsx and the #6244 audit doc are precedent to read for inspiration only — they live in the main site app and are not to be imported, moved, copied, or edited by this issue. Do not add motion or framer-motion to apps/loopover-miner-ui/package.json; use window.matchMedia("(prefers-reduced-motion: reduce)") instead, the same technique packages/loopover-ui-kit/src/hooks/use-mobile.tsx already uses. A PR that edits apps/loopover-ui/src/components/site/animated-terminal.tsx, or that adds a motion/framer-motion dependency to apps/loopover-miner-ui, does not satisfy this issue.

⚠️ This issue is plumbing only — no backend wiring. Do not add a new /api/* route, a real fetch/EventSource call to a live endpoint, or any change to any apps/loopover-miner-ui/vite-*-api.ts middleware. Every test must exercise the hook exclusively through an in-test mock chunk source (a hand-rolled async generator or ReadableStream, in the style of use-polled-fetch.test.ts's fake-timer-driven mocks) — never a real network call. A PR that wires this hook to any live endpoint does not satisfy this issue; that is separate, later work.

Requirements

  • The hook lives at apps/loopover-miner-ui/src/lib/use-streaming-text.ts and exports a function named useStreamingText, mirroring the location/export convention of apps/loopover-miner-ui/src/lib/use-polled-fetch.ts.
  • The hook accepts a chunked text source per call (an async generator function, an AsyncIterable<string>, or a ReadableStream<string> factory — implementer's choice, but export the chosen source type by name from the same file so a future composer/message-list issue can type against it).
  • The hook returns, at minimum: the text accumulated so far, a status distinguishing at least idle/streaming/done/error/cancelled, and a way to cancel the in-flight stream.
  • Supplying a new source while a previous one is still streaming must stop consumption of the previous source, and no chunk arriving from the previous source after that point may reach returned state — mirror usePolledFetch's cancelled-flag pattern (use-polled-fetch.ts:16,24,34-36).
  • Unmounting mid-stream must behave the same way: no state update may occur after unmount even if a pending chunk resolves late, matching the invariant use-polled-fetch.test.ts:76-89 already asserts for polling.
  • A chunk-source error (thrown or rejected mid-stream) must surface through the returned status/error value, not as an unhandled rejection and not silently swallowed.
  • No new npm dependency is added to apps/loopover-miner-ui/package.json. Any visual smoothing layered on top of raw chunk arrival must check reduced motion via window.matchMedia("(prefers-reduced-motion: reduce)") (the use-mobile.tsx pattern), not the motion package.
  • No new /api/* route, live fetch call, or EventSource usage is added anywhere in this PR. The only exercised source is a mock built inside the test files.
  • apps/loopover-ui/src/components/site/animated-terminal.tsx is read for precedent only and is not modified by this PR.

Deliverables

  • apps/loopover-miner-ui/src/lib/use-streaming-text.ts — the useStreamingText hook and its exported chunk-source type
  • apps/loopover-miner-ui/src/components/streaming-text.tsx — a thin presentational <StreamingText> component that calls the hook and renders the progressively-accumulated text (the "renderer" half of this issue's title), reduced-motion-aware, following the flat single-file convention already used by apps/loopover-miner-ui/src/components/grafana-footer-link.tsx (the only existing component in that directory)
  • apps/loopover-miner-ui/src/use-streaming-text.test.ts — co-located flat at the top of src/, matching apps/loopover-miner-ui/src/use-polled-fetch.test.ts's convention (not nested under lib/)
  • apps/loopover-miner-ui/src/streaming-text.test.tsx — component-level test for the renderer

Test Coverage Requirements

apps/** is in codecov.yml's ignore: list, so Codecov's 99% patch gate does not apply to these files. The operative gate is the local vitest run: apps/loopover-miner-ui/vitest.config.ts's coverage thresholds (statements: 85, branches: 85, functions: 75, lines: 85), invoked via npm run ui:test (part of root npm run test:ci, which must be fully green before pushing). Because this is entirely new, self-contained logic with nothing pre-existing to preserve, aim well above that floor — every branch of the status transitions (idle→streaming→done, streaming→cancelled, streaming→error) should be exercised, not just the happy path.

Required test cases, modeled on use-polled-fetch.test.ts's existing invariant style:

  • Progressive accumulation: a mock source yielding multiple chunks updates the returned text incrementally across renders, not only once at the end.
  • Cancellation on new source: starting a second source before the first finishes must not let a late chunk from the first source reach returned state (regression test analogous to use-polled-fetch.test.ts:76-89, "does not update the result after unmount, even if an in-flight fetch resolves late").
  • Cancellation on unmount: unmounting mid-stream, then resolving/yielding a pending chunk, must not throw and must not update any retained state.
  • Error path: a mock source that rejects or throws mid-stream surfaces via the returned status/error rather than an unhandled rejection.
  • Reduced motion: rendering <StreamingText> with window.matchMedia mocked to report prefers-reduced-motion: reduce reaches the same final text with no animation-only intermediate DOM state asserted on (assert end-state, not timing, to avoid a flaky test).

npm run ui:test, npm run ui:lint, and npm run ui:typecheck (all part of root npm run test:ci) must be green locally before pushing.

Expected Outcome

A tested useStreamingText hook and thin <StreamingText> renderer exist in apps/loopover-miner-ui, exercised only against mock chunk sources in tests, with no backend or route changes anywhere in the PR. The composer, message-list, and chat-backend issues can then consume this primitive directly once they land, without this issue itself claiming any of that follow-on wiring.

Links & Resources

  • apps/loopover-ui/src/components/site/animated-terminal.tsx — reveal-mechanics precedent (typewriter interval, reduced-motion escape hatch, fade-in reveal); read-only reference, not to be edited
  • apps/loopover-ui/src/chat-ui-primitives-audit.md — the research: audit ui-kit for existing message/chat-adjacent UI primitives #6244 audit (merged via PR docs(ui): audit ui-kit for existing chat-adjacent UI primitives #6474) that names this exact precedent and its gaps
  • apps/loopover-ui/src/lib/analytics-proxy.ts — the one other streaming-related hit in the repo (a comment explaining why that proxy avoids streaming)
  • apps/loopover-miner-ui/src/lib/use-polled-fetch.ts and apps/loopover-miner-ui/src/use-polled-fetch.test.ts — the hook-shape and cancellation-testing convention to mirror
  • packages/loopover-ui-kit/src/hooks/use-mobile.tsx — the window.matchMedia-driven hook pattern to reuse for reduced-motion detection
  • apps/loopover-miner-ui/vitest.config.ts and codecov.yml — the local coverage bar that actually gates this PR
  • apps/loopover-miner-ui/src/components/grafana-footer-link.tsx — the existing flat-component convention in this app's components/ directory
  • research: audit ui-kit for existing message/chat-adjacent UI primitives #6244 / PR docs(ui): audit ui-kit for existing chat-adjacent UI primitives #6474 — the merged audit this issue's precedent citation is drawn from
  • Spec: conversational chat interface for loopover (Lovable/Cursor-style) #6230 — the separate, still-open maintainer-chat spec (different app, explicitly out of scope here)
  • Related, not yet filed: the persistent-chat-rail-shell issue (mounts the chat rail in __root.tsx, will eventually consume this hook), the message-list issue (blocked on porting state-views.tsx's StateBoundary family into @loopover/ui-kit first, per the design synthesis), and the chat-backend/grounding issue (will supply the first real chunk source)

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:featureGittensor-scored feature linked to a feature issue — scores a 0.25x multiplier.help wantedExtra attention is needed

    Projects

    Status
    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions