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 (~380px) mounted once in apps/loopover-miner-ui/src/routes/__root.tsx so it survives route navigation across all four existing routes (index/run-history/portfolio/ledgers). That rail needs a message-input control, and today there isn't one anywhere in this codebase to reuse.
The chat-adjacent UI primitives audit (#6244, landed via PR #6474 as apps/loopover-ui/src/chat-ui-primitives-audit.md) checked this specifically and confirmed the gap is real, not assumed:
Entirely absent: "a submit-on-Enter composer (no Enter/Shift+Enter wiring or auto-grow anywhere)" — grepped across the app, nothing implements this behavior today.
Reusable as raw building blocks only: packages/loopover-ui-kit/src/components/textarea.tsx and packages/loopover-ui-kit/src/components/button.tsx, explicitly called out as having "no submit/auto-grow logic" of their own. Confirmed by reading both files directly: textarea.tsx is a bare React.forwardRef wrapping a plain <textarea> with only class-name styling and no onKeyDown/resize handling; button.tsx is a bare cva-variant wrapper (buttonVariants) with no chat-specific behavior either.
Both apps/loopover-miner-ui and apps/loopover-ui already consume @loopover/ui-kit as a real npm workspace dependency (apps/loopover-miner-ui/package.json:18, ">=0.1.0 <2.0.0"), not a vendored copy, and this app already imports components straight from the package — e.g. apps/loopover-miner-ui/src/routes/portfolio.tsx:4: import { Button } from "@loopover/ui-kit/components/button";. No adaptation is needed to consume Textarea/Button the same way.
This issue is scoped to the composer only. The message-list component (a separate piece of the same chat rail) has its own prerequisite — porting apps/loopover-ui/src/components/site/state-views.tsx's LoadingState/EmptyState/ErrorState/StateBoundary into @loopover/ui-kit — because it renders its own async fetch state. The composer does none of that: it is a pure, stateless-from-the-outside input control driven entirely by props and a submit callback, so it has no such dependency and can be built and merged independently.
This is contributor-eligible on ordinary grounds: it is a self-contained UI component with no backend call, no data access, and no action surface — it takes an onSubmit callback and never itself calls an API, an MCP tool, or a governor/portfolio-queue endpoint.
Requirements
⚠️ Read this before starting. This issue is a new, standalone component file, not an edit to packages/loopover-ui-kit and not a route/rail wiring change. A PR that (a) adds submit/auto-grow logic into packages/loopover-ui-kit/src/components/textarea.tsx or button.tsx directly, (b) wires the component into apps/loopover-miner-ui/src/routes/__root.tsx or any route file, or (c) adds any fetch/API/MCP call to the component does not resolve this issue and will be closed.
Create apps/loopover-miner-ui/src/components/chat-composer.tsx exporting a named ChatComposer React component — matching this app's existing flat src/components/*.tsx + named-export convention (see apps/loopover-miner-ui/src/components/grafana-footer-link.tsx, the only existing file in that directory).
Build the textarea from the ui-kit's raw Textarea primitive: import { Textarea } from "@loopover/ui-kit/components/textarea";. Build the submit control from the ui-kit's raw Button primitive: import { Button } from "@loopover/ui-kit/components/button";. Do not reimplement a bare <textarea>/<button> element from scratch, and do not add any new component or edit any existing component under packages/loopover-ui-kit/** — that package stays a behavior-less primitives layer; the submit-on-Enter/auto-grow logic belongs entirely in the new app-level file.
Pressing Enter with no modifier key submits the current textarea value via an onSubmit(message: string) callback prop, then clears the textarea.
Pressing Shift+Enter inserts a literal newline into the textarea and does not submit.
Clicking the Button submits identically to pressing Enter (same value handling as the keyboard path).
Empty or whitespace-only content must not call onSubmit, whether triggered by Enter or by clicking the button — trim before the emptiness check so a value of " " is blocked exactly like "".
The textarea auto-grows its height to fit typed content (including pasted multi-line content) up to a maximum height, beyond which it stops growing and scrolls internally instead.
The component owns its own value state internally (uncontrolled from the caller's perspective) — the only props it needs are the onSubmit callback and presentational props (e.g. placeholder, disabled). Do not add any prop, state, or effect that reads from or writes to a backend, route loader, lib/*.ts fetcher, or MCP tool — none of that exists in this component's scope.
Do not touch apps/loopover-miner-ui/src/routes/__root.tsx or any file under apps/loopover-miner-ui/src/routes/. This component ships unwired; a future issue mounts it into the persistent chat rail.
Deliverables
apps/loopover-miner-ui/src/components/chat-composer.tsx — the new ChatComposer component, built from the ui-kit's raw Textarea/Button primitives, implementing submit-on-Enter, Shift+Enter-newline, auto-grow-with-cap, and the empty/whitespace submit guard.
apps/loopover-miner-ui/src/chat-composer.test.tsx — unit tests driven entirely by fixture props/callbacks (a mock onSubmit), colocated at src/ root per this app's existing convention (see below), covering every branch listed under Test Coverage Requirements.
No changes to any file under packages/loopover-ui-kit/**.
No changes to apps/loopover-miner-ui/src/routes/**.
Test Coverage Requirements
Codecov's 99% patch gate does not apply to this file: the root codecov.yml's ignore: list explicitly excludes apps/**, and the root vitest.config.ts's coverage.exclude does the same. This is expected and not a gap to "fix" — don't add apps/** coverage wiring to the root config as part of this PR.
What does gate this file is apps/loopover-miner-ui/vitest.config.ts's own local coverage floor (statements: 85, branches: 85, functions: 75, lines: 85 — a baseline floor per that file's own comment, not a ratchet), enforced by npm --workspace @loopover/ui-miner run test (aliased as npm run ui:test at the repo root, which is itself part of npm run test:ci). Run it locally before opening the PR.
Even though Codecov doesn't gate apps/**, follow house convention and aim for full branch coverage of the new file specifically:
Enter submits vs. Shift+Enter inserts a newline (both branches).
Non-empty submit vs. whitespace-only submit blocked (both branches), via both the Enter path and the button-click path.
Textarea clears after a successful submit.
Auto-grow triggers below the height cap, and stops growing (scrolls instead) once content exceeds the cap — both branches.
jsdom gotcha, flagged explicitly: jsdom does not compute real CSS layout, so a freshly-rendered <textarea>'s scrollHeight is always 0 in tests, not a real pixel value. Auto-grow tests must stub scrollHeight on the rendered element (e.g. Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: N }) before firing an input event) and assert the component reacted to it (e.g. the inline height style was set, or growth stopped once the stubbed value exceeds the cap) — do not attempt to assert a real measured pixel height, jsdom will not produce one.
Match this app's existing accessible-query test style rather than querying by class name or test-id — see apps/loopover-miner-ui/src/grafana-footer-link.test.tsx for the getByRole-based pattern already used here (screen.getByRole("textbox"), screen.getByRole("button", { name: ... })).
Expected Outcome
A fully unit-tested ChatComposer component exists in apps/loopover-miner-ui, with submit-on-Enter, Shift+Enter-newline, auto-grow-with-cap, and the empty-submit guard all verified through fixture-driven tests — with zero coupling to any route, __root.tsx, backend endpoint, or MCP/action-dispatch path. It is ready to be dropped into the persistent chat rail by a later issue without further behavioral changes.
packages/loopover-ui-kit/src/components/textarea.tsx, packages/loopover-ui-kit/src/components/button.tsx — the raw primitives to build from; do not edit either file.
apps/loopover-miner-ui/src/components/grafana-footer-link.tsx and its test apps/loopover-miner-ui/src/grafana-footer-link.test.tsx — this app's existing precedent for component-file location, named-export style, and colocated-at-src/-root test placement.
apps/loopover-miner-ui/src/routes/portfolio.tsx:4 — precedent for the @loopover/ui-kit/components/* import path this component should use.
apps/loopover-miner-ui/vitest.config.ts — the local coverage thresholds this PR is actually measured against (Codecov ignores apps/**).
Related but not blocking: the chat message-list component (needs the state-views.tsxStateBoundary port into @loopover/ui-kit first — this composer has no such dependency) and the persistent chat rail shell that will eventually mount this composer into __root.tsx.
Context
The miner dashboard redesign adds a persistent, collapsible chat rail (~380px) mounted once in
apps/loopover-miner-ui/src/routes/__root.tsxso it survives route navigation across all four existing routes (index/run-history/portfolio/ledgers). That rail needs a message-input control, and today there isn't one anywhere in this codebase to reuse.The chat-adjacent UI primitives audit (#6244, landed via PR #6474 as
apps/loopover-ui/src/chat-ui-primitives-audit.md) checked this specifically and confirmed the gap is real, not assumed:Enter/Shift+Enterwiring or auto-grow anywhere)" — grepped across the app, nothing implements this behavior today.packages/loopover-ui-kit/src/components/textarea.tsxandpackages/loopover-ui-kit/src/components/button.tsx, explicitly called out as having "no submit/auto-grow logic" of their own. Confirmed by reading both files directly:textarea.tsxis a bareReact.forwardRefwrapping a plain<textarea>with only class-name styling and noonKeyDown/resize handling;button.tsxis a barecva-variant wrapper (buttonVariants) with no chat-specific behavior either.Both
apps/loopover-miner-uiandapps/loopover-uialready consume@loopover/ui-kitas a real npm workspace dependency (apps/loopover-miner-ui/package.json:18,">=0.1.0 <2.0.0"), not a vendored copy, and this app already imports components straight from the package — e.g.apps/loopover-miner-ui/src/routes/portfolio.tsx:4:import { Button } from "@loopover/ui-kit/components/button";. No adaptation is needed to consumeTextarea/Buttonthe same way.This issue is scoped to the composer only. The message-list component (a separate piece of the same chat rail) has its own prerequisite — porting
apps/loopover-ui/src/components/site/state-views.tsx'sLoadingState/EmptyState/ErrorState/StateBoundaryinto@loopover/ui-kit— because it renders its own async fetch state. The composer does none of that: it is a pure, stateless-from-the-outside input control driven entirely by props and a submit callback, so it has no such dependency and can be built and merged independently.This is contributor-eligible on ordinary grounds: it is a self-contained UI component with no backend call, no data access, and no action surface — it takes an
onSubmitcallback and never itself calls an API, an MCP tool, or a governor/portfolio-queue endpoint.Requirements
apps/loopover-miner-ui/src/components/chat-composer.tsxexporting a namedChatComposerReact component — matching this app's existing flatsrc/components/*.tsx+ named-export convention (seeapps/loopover-miner-ui/src/components/grafana-footer-link.tsx, the only existing file in that directory).Textareaprimitive:import { Textarea } from "@loopover/ui-kit/components/textarea";. Build the submit control from the ui-kit's rawButtonprimitive:import { Button } from "@loopover/ui-kit/components/button";. Do not reimplement a bare<textarea>/<button>element from scratch, and do not add any new component or edit any existing component underpackages/loopover-ui-kit/**— that package stays a behavior-less primitives layer; the submit-on-Enter/auto-grow logic belongs entirely in the new app-level file.Enterwith no modifier key submits the current textarea value via anonSubmit(message: string)callback prop, then clears the textarea.Shift+Enterinserts a literal newline into the textarea and does not submit.Buttonsubmits identically to pressingEnter(same value handling as the keyboard path).onSubmit, whether triggered byEnteror by clicking the button — trim before the emptiness check so a value of" "is blocked exactly like"".valuestate internally (uncontrolled from the caller's perspective) — the only props it needs are theonSubmitcallback and presentational props (e.g.placeholder,disabled). Do not add any prop, state, or effect that reads from or writes to a backend, route loader,lib/*.tsfetcher, or MCP tool — none of that exists in this component's scope.apps/loopover-miner-ui/src/routes/__root.tsxor any file underapps/loopover-miner-ui/src/routes/. This component ships unwired; a future issue mounts it into the persistent chat rail.Deliverables
apps/loopover-miner-ui/src/components/chat-composer.tsx— the newChatComposercomponent, built from the ui-kit's rawTextarea/Buttonprimitives, implementing submit-on-Enter, Shift+Enter-newline, auto-grow-with-cap, and the empty/whitespace submit guard.apps/loopover-miner-ui/src/chat-composer.test.tsx— unit tests driven entirely by fixture props/callbacks (a mockonSubmit), colocated atsrc/root per this app's existing convention (see below), covering every branch listed under Test Coverage Requirements.packages/loopover-ui-kit/**.apps/loopover-miner-ui/src/routes/**.Test Coverage Requirements
codecov.yml'signore:list explicitly excludesapps/**, and the rootvitest.config.ts'scoverage.excludedoes the same. This is expected and not a gap to "fix" — don't addapps/**coverage wiring to the root config as part of this PR.apps/loopover-miner-ui/vitest.config.ts's own local coverage floor (statements: 85, branches: 85, functions: 75, lines: 85— a baseline floor per that file's own comment, not a ratchet), enforced bynpm --workspace @loopover/ui-miner run test(aliased asnpm run ui:testat the repo root, which is itself part ofnpm run test:ci). Run it locally before opening the PR.apps/**, follow house convention and aim for full branch coverage of the new file specifically:Entersubmits vs.Shift+Enterinserts a newline (both branches).Enterpath and the button-click path.<textarea>'sscrollHeightis always0in tests, not a real pixel value. Auto-grow tests must stubscrollHeighton the rendered element (e.g.Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: N })before firing an input event) and assert the component reacted to it (e.g. the inline height style was set, or growth stopped once the stubbed value exceeds the cap) — do not attempt to assert a real measured pixel height, jsdom will not produce one.apps/loopover-miner-ui/src/grafana-footer-link.test.tsxfor thegetByRole-based pattern already used here (screen.getByRole("textbox"),screen.getByRole("button", { name: ... })).Expected Outcome
A fully unit-tested
ChatComposercomponent exists inapps/loopover-miner-ui, with submit-on-Enter, Shift+Enter-newline, auto-grow-with-cap, and the empty-submit guard all verified through fixture-driven tests — with zero coupling to any route,__root.tsx, backend endpoint, or MCP/action-dispatch path. It is ready to be dropped into the persistent chat rail by a later issue without further behavioral changes.Links & Resources
apps/loopover-ui/src/chat-ui-primitives-audit.md(from research: audit ui-kit for existing message/chat-adjacent UI primitives #6244, merged via PR docs(ui): audit ui-kit for existing chat-adjacent UI primitives #6474) — source of the "entirely absent" / "raw primitives only" findings this issue is built on.packages/loopover-ui-kit/src/components/textarea.tsx,packages/loopover-ui-kit/src/components/button.tsx— the raw primitives to build from; do not edit either file.apps/loopover-miner-ui/src/components/grafana-footer-link.tsxand its testapps/loopover-miner-ui/src/grafana-footer-link.test.tsx— this app's existing precedent for component-file location, named-export style, and colocated-at-src/-root test placement.apps/loopover-miner-ui/src/routes/portfolio.tsx:4— precedent for the@loopover/ui-kit/components/*import path this component should use.apps/loopover-miner-ui/vitest.config.ts— the local coverage thresholds this PR is actually measured against (Codecov ignoresapps/**).state-views.tsxStateBoundaryport into@loopover/ui-kitfirst — this composer has no such dependency) and the persistent chat rail shell that will eventually mount this composer into__root.tsx.