Skip to content

Chat UI: composer component (submit-on-Enter, Shift+Enter newline, auto-grow) #6514

Description

@JSONbored

Context

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.

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.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.tsx StateBoundary 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.

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