You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:test → npm --workspace @loopover/ui-miner run test → vitest 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, notapps/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/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
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)
Context
The miner dashboard redesign adds a persistent, collapsible chat rail to
apps/loopover-miner-ui(mounted once inroutes/__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-uihas no chat surface and no streaming consumer today. The closest thing in the whole monorepo isapps/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": asetInterval-driven character-by-character typewriter over a hardcodedscene.promptstring, amotion.prefade-in forscene.outputviaAnimatePresence, and auseReducedMotion()escape hatch that skips the animation outright (animated-terminal.tsx:68-84,TYPE_SPEED/HOLDconsts 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 ofapps/loopover-ui/srcforEventSource|ReadableStream|text/event-stream|streaming— that the only other hit in the whole app is a comment inapps/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 makesapps/loopover-miner-uithe first one, but only as a tested, unwired primitive — it is explicitly not yet connected to a real backend.apps/loopover-miner-uialready has one hook of exactly this shape to model the house convention on:apps/loopover-miner-ui/src/lib/use-polled-fetch.tsexportsusePolledFetch, tracks an in-effectcancelledflag so a resolve-after-unmount/after-restart never touches state (use-polled-fetch.ts:16,24,34-36), and its co-located test fileapps/loopover-miner-ui/src/use-polled-fetch.test.tssits flat at the top ofsrc/(not nested underlib/), usesvi.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).useStreamingTextshould 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-uimatter for how this gets built:motiondependency here.apps/loopover-ui/package.json:68depends on"motion": "^12.42.2"(whatanimated-terminal.tsxuses forAnimatePresence/useReducedMotion);apps/loopover-miner-ui/package.jsonhas no such dependency. The existing reduced-motion-detection precedent that is already available to this app ispackages/loopover-ui-kit/src/hooks/use-mobile.tsx'suseIsMobile, which drives a boolean offwindow.matchMedia(...)with achangelistener (lines 9-15) — the same technique works forprefers-reduced-motion.codecov.yml'signore:list includes"apps/**"— this PR's files are not measured by the 99% patch gate. The operative local bar isapps/loopover-miner-ui/vitest.config.ts's coveragethresholds(statements: 85, branches: 85, functions: 75, lines: 85), enforced bynpm run ui:test→npm --workspace @loopover/ui-miner run test→vitest 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 portingapps/loopover-ui/src/components/site/state-views.tsx'sStateBoundaryfamily into@loopover/ui-kitfirst — and the chat-backend/grounding issue). This issue is also unrelated to #6230 (the separate, still-open maintainer-chat spec, which lives inapps/loopover-ui) — that scope is explicitly excluded from the miner dashboard redesign this issue belongs to.Requirements
apps/loopover-miner-ui/src/lib/use-streaming-text.tsand exports a function nameduseStreamingText, mirroring the location/export convention ofapps/loopover-miner-ui/src/lib/use-polled-fetch.ts.AsyncIterable<string>, or aReadableStream<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).usePolledFetch'scancelled-flag pattern (use-polled-fetch.ts:16,24,34-36).use-polled-fetch.test.ts:76-89already asserts for polling.apps/loopover-miner-ui/package.json. Any visual smoothing layered on top of raw chunk arrival must check reduced motion viawindow.matchMedia("(prefers-reduced-motion: reduce)")(theuse-mobile.tsxpattern), not themotionpackage./api/*route, livefetchcall, orEventSourceusage 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.tsxis read for precedent only and is not modified by this PR.Deliverables
apps/loopover-miner-ui/src/lib/use-streaming-text.ts— theuseStreamingTexthook and its exported chunk-source typeapps/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 byapps/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 ofsrc/, matchingapps/loopover-miner-ui/src/use-polled-fetch.test.ts's convention (not nested underlib/)apps/loopover-miner-ui/src/streaming-text.test.tsx— component-level test for the rendererTest Coverage Requirements
apps/**is incodecov.yml'signore: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 coveragethresholds(statements: 85, branches: 85, functions: 75, lines: 85), invoked vianpm run ui:test(part of rootnpm 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:use-polled-fetch.test.ts:76-89, "does not update the result after unmount, even if an in-flight fetch resolves late").<StreamingText>withwindow.matchMediamocked to reportprefers-reduced-motion: reducereaches 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, andnpm run ui:typecheck(all part of rootnpm run test:ci) must be green locally before pushing.Expected Outcome
A tested
useStreamingTexthook and thin<StreamingText>renderer exist inapps/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 editedapps/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 gapsapps/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.tsandapps/loopover-miner-ui/src/use-polled-fetch.test.ts— the hook-shape and cancellation-testing convention to mirrorpackages/loopover-ui-kit/src/hooks/use-mobile.tsx— thewindow.matchMedia-driven hook pattern to reuse for reduced-motion detectionapps/loopover-miner-ui/vitest.config.tsandcodecov.yml— the local coverage bar that actually gates this PRapps/loopover-miner-ui/src/components/grafana-footer-link.tsx— the existing flat-component convention in this app'scomponents/directory__root.tsx, will eventually consume this hook), the message-list issue (blocked on portingstate-views.tsx'sStateBoundaryfamily into@loopover/ui-kitfirst, per the design synthesis), and the chat-backend/grounding issue (will supply the first real chunk source)