From c516f6c5b3a20394861cdf0b44b7d2c98cb6c979 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:05:18 +0000 Subject: [PATCH 1/2] docs: design standalone composer input + question tool in quill Design-only pass. Documents extracting code's PromptInput presentational shell into a host-agnostic @posthog/quill Composer (composed over the existing InputGroup, whose block-aligned addons already grow the box), and a question surface that reuses the same shell so the bottom input reads as continuous and becomes a "talk about it" input when a question tool is active. No production code changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sNchSXj5YTpcCi72yC3LY --- docs/plans/standalone-prompt-input-quill.md | 318 ++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 docs/plans/standalone-prompt-input-quill.md diff --git a/docs/plans/standalone-prompt-input-quill.md b/docs/plans/standalone-prompt-input-quill.md new file mode 100644 index 0000000000..d57adac8a6 --- /dev/null +++ b/docs/plans/standalone-prompt-input-quill.md @@ -0,0 +1,318 @@ +# Design: Standalone composer input in quill + question tool + +Status: design-review (no code yet) +Author: Adam Leith +Scope: `@posthog/quill` (source in `posthog/posthog` at `packages/quill`) + `@posthog/ui` in `posthog/code` + +## Goal + +Move the presentational *shell* of `code`'s prompt input into `@posthog/quill` as a +reusable, host-agnostic composer, so every surface renders the same box. Then add a +**question tool** surface that reuses that same shell, so when the agent asks a +question the composer *appears to stay pinned at the bottom* and becomes a +"talk about it" input — visually identical to the message composer, even though it +is a different element with different behavior. + +### Non-goals (this pass) + +- No editor engine in quill. Tiptap stays in `code`. Quill owns layout only. +- No change to draft/attachment/mode/skills logic. Those stay in `code` as slotted children. +- No new "grow" mechanism — quill's `InputGroup` already grows for block-aligned addons. + +## Decisions locked in review + +1. **Design/API only** for now — this document. Implementation follows in a later pass. +2. **Pure presentational shell** — quill owns `InputGroup` composition, addon slots, + toolbar row, submit/stop button styling, states, and grow behavior. All tiptap / ACP / + store logic stays in `code` and is passed as children/slots. +3. **Question surface = same shell.** The message composer and the question surface must + look and sit identically so the bottom input reads as continuous. They may be different + React elements, but they render the same quill shell. + +--- + +## 1. What exists today + +`packages/ui/src/features/message-editor/components/PromptInput.tsx` (473 lines) is +**already** built on quill's `InputGroup` / `InputGroupAddon` / `InputGroupButton`. Anatomy: + +``` +InputGroup (focus ring, bash-mode ring, cursor-text, grows via block addons) +├─ InputGroupAddon align="block-start" → headerAddon slot + +├─ div.cli-editor-scroll (min-h 50px, max-h 200px|45vh, overflow-y) +│ └─ ← domain (tiptap) +└─ InputGroupAddon align="block-end" (toolbar) + ├─ {modelSelector} {reasoningSelector} + │ {messagingModeToggle} {bash indicator} ← domain, some already slots + └─ span.ml-auto → {historyButton} + submit/stop + (+ optional sibling outside the box) +``` + +**Already slot-shaped** (passed as `ReactNode` props): `modelSelector`, +`reasoningSelector`, `messagingModeToggle`, `historyButton`, `headerAddon`. The refactor +formalizes and completes this pattern. + +**Domain guts that must stay in `code` and be passed in:** + +| Piece | Why it can't be in quill | +| --- | --- | +| `useTiptapEditor` + `` | Tiptap, ACP SDK, `draftStore`, skills, commands | +| `AttachmentMenu`, `AttachmentsBar` | host file API, chip insertion | +| `ModeSelector` | ACP `SessionConfigOption`, `sessionStore` | +| `ModelSelector`, `ReasoningLevelSelector`, `SteerQueueToggle` | already slotted | +| `SlotMachineSubmit`, history dialog | app state / settings | +| hotkeys, focus mgmt, submit/cancel logic, `EditorHandle` imperative API | app behavior | + +**Consumers to keep working (5):** `SessionView` (main chat composer), `TaskInput` +(new-task), `PiSessionView`, `FreeformGenerateBar` (canvas), `ChannelHomeComposer` (channels). + +### The "grow" is already free + +`packages/quill/packages/primitives/src/input-group.css`: + +```css +.quill-input-group:has(> [data-align='block-start']), +.quill-input-group:has(> [data-align='block-end']) { + flex-direction: column; + height: auto; /* container grows to fit children */ +} +``` + +So "the input grows to fit the question tool's children" needs no new mechanism — the +question content is a `block-start` addon inside the same `InputGroup`. **The +`InputGroup` primitive is enough** for the growth, confirming the hunch. + +### The question tool today + +- `permissions/QuestionPermission.tsx` parses the tool's `_meta` questions and renders + `primitives/action-selector/ActionSelector` (option rows, single/multi-select, step + tabs, inline custom input, submit/cancel; selection + keyboard nav live in + `useActionSelectorState`). +- `session-update/QuestionToolView.tsx` renders the collapsed transcript row. +- In `SessionView.tsx` the composer slot is an **either/or**: a pending question renders + the `ActionSelector` card **instead of** `PromptInput` (`:646` vs `:655`). This is the + swap we want to replace with continuity. + +--- + +## 2. Proposed quill additions + +Layer: **`@posthog/quill-components`** (composed primitives), not a new primitive — the +composer composes `InputGroup` + `Button`, which is exactly the components layer's job. +The question-option row and step tabs reuse existing primitives (`Item`, `Tabs`) where possible. + +### 2a. `Composer` — the shell (compositional API) + +Mirrors quill's composition-over-props ethos (like `Card` / `Item`). Every part is a thin +wrapper over an `InputGroup` slot so the box, grow, and focus states come for free. + +```tsx + {/* InputGroup + variants + grow */} + {/* block-start addon: attachments, context chips, question tool */} + {editor} {/* scroll area; children = from code */} + {/* block-end addon */} + {/* attach, mode, model, reasoning */} + {/* history + */} + + +``` + +| Component | Wraps | Owns | +| --- | --- | --- | +| `Composer` | `InputGroup` | focus/hover ring, `cursor-text`, `state` variant, grow, `onClick`-to-focus escape (via `onFocusRequest` callback — quill can't reach the editor, so it exposes the intent) | +| `ComposerHeader` | `InputGroupAddon align="block-start"` | top padding, full-width stacking | +| `ComposerBody` | scroll `div` | `min-h`, `max-h` (`size` prop: `default` 200px / `large` 45vh), `overflow-y`, text size | +| `ComposerToolbar` | `InputGroupAddon align="block-end"` | 1px padding, row layout | +| `ComposerToolbarStart` / `ComposerToolbarEnd` | `span` | left cluster / `ml-auto` right cluster | +| `ComposerSubmit` | `InputGroupButton` | sizing + variant presets; children = icon | + +**`Composer` props (presentational only):** + +| Prop | Type | Purpose | +| --- | --- | --- | +| `state` | `"default" \| "accent" \| "invalid"` | `accent` = the blue bash-mode ring; `invalid` = destructive ring. Default relies on `InputGroup` focus-within. | +| `size` | `"default" \| "large"` | forwards to `ComposerBody` max-height | +| `onFocusRequest` | `() => void` | click on empty chrome → code focuses its editor (quill has no editor ref) | +| `className`, `...div` | | passthrough | + +**`ComposerSubmit` props:** + +| Prop | Type | Purpose | +| --- | --- | --- | +| `intent` | `"send" \| "stop"` | `send` = primary; `stop` = destructive. Icon passed as children (code uses phosphor; quill stays icon-agnostic). | +| `disabled`, `onClick`, `aria-label` | | standard | + +Icons: `code` passes phosphor `ArrowUp` / `Stop` as children — quill does not import an +icon set for these, keeping it host-agnostic (quill's own examples use lucide, but the +button accepts any node). + +**Why a `Composer` wrapper instead of "just use `InputGroup`":** the five call sites each +re-hand-roll the scroll `div` classes, the `ml-auto` submit cluster, the focus-ring +className, and the `block-start`/`block-end` addon choreography. `Composer` makes that one +opinionated, tested, storybook'd surface — and, crucially, is what lets the **question +surface reuse the identical shell** (section 3) rather than approximating it. + +### 2b. Question-option primitives (probably mostly existing quill) + +The presentational question surface is: an optional step-tab strip, a title + question +line, a list of selectable option rows (single or multi, each with a label + description), +and submit/cancel actions. Most of this already exists in quill: + +| Need | Existing quill | Gap | +| --- | --- | --- | +| selectable option rows | `Item variant="pressable"` + `ItemContent`/`ItemTitle`/`ItemDescription`, `tone` | a selected/checked visual + radio/checkbox affordance + keyboard roving | +| multi-select checkmark | `ItemGroup` + `Checkbox` | compose | +| step tabs | `Tabs variant="line"` | completed-state dot | +| custom "Other" input | `Composer` body itself (see §3) | — | + +Proposal: add a thin **`QuestionOptions` / `QuestionOption`** pair (or a `selectable` +variant on `Item`) that renders the option row with a radio/checkbox slot, description, and +`data-selected`/keyboard support — and a `QuestionSteps` wrapper over `Tabs` with a +`completed` dot. Selection state, keyboard handling, and step advancement **stay in +`code`** (today's `useActionSelectorState`); quill only styles the rows. + +Open call for review: this may be light enough that we keep the whole `ActionSelector` +option-row rendering in `code` and only ship `Composer`. See Open Questions Q2. + +--- + +## 3. The question surface — continuity at the bottom + +Requirement from review: *the input seemingly stays at the bottom and becomes the +"talk about it" input when a question is available; it must look exactly the same.* + +Reconciliation: **one `Composer` shell always occupies the bottom.** Its *contents* change +with mode. There is no swap of the visible box — only its header and toolbar action swap. + +### Message mode (default) + +``` +Composer +├─ ComposerHeader → attachments (when present) +├─ ComposerBody → tiptap editor ("Type a message…") +└─ ComposerToolbar → [attach · mode · model · reasoning] … [history · Send] +``` + +### Question mode (a question tool is pending) + +``` +Composer ← same shell, same position, grows upward +├─ ComposerHeader → + question title/text + +├─ ComposerBody → tiptap editor, placeholder "Talk about it…" ← the "talk about it" input +└─ ComposerToolbar → [mode?] … [Submit answer] (Send becomes Submit; Enter in body = discuss) +``` + +- The **options render in `ComposerHeader`** (block-start addon) so the box grows upward + from the pinned bottom — nothing below moves, the input appears to stay put and expand. +- The **body stays a live editor** as the "talk about it" / free-text / "Other" answer. + Typing + Enter there submits a discussion message (or the custom answer); the toolbar's + **Submit answer** commits the selected option(s). This is exactly the "becomes the talk + about it input" behavior. +- Because both modes render the **same `Composer`**, the transition is a content change, + not a remount — true visual continuity, no fl. The user's "it may be a different + element but must look identical" is satisfied by shared shell: even if `code` chooses to + mount a distinct `` component, it renders `Composer` internally, so it + is pixel-identical to ``. + +### Recommended code structure (later pass) + +- `MessageComposer` (rename/thin-wrap today's `PromptInput`) → renders `Composer` with the + message-mode slots. Keeps `EditorHandle`, hotkeys, submit logic. +- `QuestionComposer` → renders `Composer` with question-mode slots; drives selection via + today's `useActionSelectorState` + `questionDraftStore`; shares the same `useTiptapEditor` + for the "talk about it" body. +- `SessionView` composer slot stops doing either/or of *different-looking* cards; it always + renders a `Composer`-shaped element, switching message ↔ question contents. + +--- + +## 4. `PromptInput` before / after + +**Before:** `PromptInput` owns the box classes, scroll div, toolbar layout, submit cluster, +and grow choreography inline, plus all domain wiring. + +**After:** `PromptInput` (→ `MessageComposer`) keeps only domain wiring and passes slots: + +```tsx + + {headerAddon && {headerAddon}} + {attachments.length > 0 && ( + + )} + + + + {!hideDefaultToolbar && <>{onModeChange && }{modelSelector}{reasoningSelector}{messagingModeToggle}{isBashMode && }} + + + {!hideDefaultToolbar && historyButton} + {inStopMode + ? + : !slotMachineMode && } + + + +``` + +`SlotMachineSubmit` stays a `code`-side sibling. The `EditorHandle` `useImperativeHandle`, +all `useHotkeys`, `useDraftStore`, skills effect, and callbacks are unchanged. + +--- + +## 5. Migration plan (phased) + +1. **Quill:** add `Composer*` (+ optional `QuestionOptions`/`QuestionSteps`) to + `packages/quill/packages/components`, export from that package's `src/index.ts` (flows to + `@posthog/quill` on build). Storybook stories for message mode, grown/attachments mode, + question mode. Update `packages/components/AGENTS.md` (lint-staged enforces this). +2. **Local sync into `code`:** build quill workspace → `npm pack` → point the + `pnpm-workspace.yaml` override at the `.local-quill/*.tgz` → `pnpm install` (the + `quill-code` skill loop). Temporary; reverted before merge. +3. **`code`:** refactor `PromptInput` → `MessageComposer` over `Composer` with **zero + behavior change**. Land + verify all 5 consumers unchanged (Storybook + `PromptInput.test` + + `test-electron-app`). +4. **`code`:** add `QuestionComposer` over `Composer`; switch `SessionView`'s composer slot + from the either/or swap to the shared-shell content switch. Migrate `QuestionPermission` + rendering into it. +5. **Publish** the quill version, bump the catalog pin in `code`, drop the tarball override. +6. Roll the shared shell into the other consumers as desired. + +Steps 3 and 4 are independently shippable; 4 depends on 3. + +--- + +## 6. Risks / open questions + +- **Q1 — layer.** `Composer` as a quill *component* (composed primitives) vs a *block* + (product pattern). Recommendation: component, since it's a generic input shell, not a + product-specific card. Confirm. +- **Q2 — how much of the question tool goes to quill.** Ship only `Composer` and keep the + `ActionSelector` option-row rendering in `code`? Or extract `QuestionOptions`/`QuestionSteps` + to quill too? Recommendation: start with `Composer` only (unblocks the continuity win); + extract option rows to quill later if reuse appears. The "talk about it" continuity does + **not** require the option rows to be in quill. +- **Q3 — icons.** Quill stays icon-agnostic for `ComposerSubmit` (children). `code` keeps + phosphor. OK? +- **Q4 — bash-mode / focus ring.** Encoded as `state="accent"` on `Composer` vs a + `className` passthrough. Recommendation: a small named `state` set so the ring is a quill + concern, not a magic className. Confirm the variant vocabulary (`default`/`accent`/`invalid`). +- **Q5 — `onFocusRequest`.** Quill can't hold the editor ref, so click-on-chrome-to-focus is + surfaced as a callback. Acceptable seam? +- **Q6 — `EditorHandle`.** Stays entirely in `code`; quill exposes no imperative handle. Confirm. + +--- + +## Appendix: file map + +Quill (new, in `posthog/posthog`): +- `packages/quill/packages/components/src/composer.tsx` (+ `.css`, `.stories.tsx`) +- (maybe) `packages/quill/packages/components/src/question-options.tsx` +- export additions in `packages/quill/packages/components/src/index.ts` +- `packages/quill/packages/components/AGENTS.md` update + +Code (later passes, in `posthog/code`): +- `packages/ui/src/features/message-editor/components/PromptInput.tsx` → `MessageComposer` +- `packages/ui/src/features/permissions/QuestionComposer.tsx` (new) +- `packages/ui/src/features/sessions/components/SessionView.tsx` (composer slot switch) +- unchanged domain: `useTiptapEditor`, `draftStore`, `AttachmentMenu`, `ModeSelector`, + `useActionSelectorState`, `questionDraftStore` From 361a3f99e9be7a98042a6e9381eead9d695391fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:07:09 +0000 Subject: [PATCH 2/2] docs: make composer motion opt-out, never forced Add a motion policy to the composer design: quill ships a default grow/expand transition (box expanding upward when a question tool activates) but routes it through an overridable CSS custom property + `motion` prop, so consumers can go to `transition: none` or any curve without fighting the library. Reduced-motion is a floor, explicit consumer values still win. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015sNchSXj5YTpcCi72yC3LY --- docs/plans/standalone-prompt-input-quill.md | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/docs/plans/standalone-prompt-input-quill.md b/docs/plans/standalone-prompt-input-quill.md index d57adac8a6..42999fa4a5 100644 --- a/docs/plans/standalone-prompt-input-quill.md +++ b/docs/plans/standalone-prompt-input-quill.md @@ -28,6 +28,10 @@ is a different element with different behavior. 3. **Question surface = same shell.** The message composer and the question surface must look and sit identically so the bottom input reads as continuous. They may be different React elements, but they render the same quill shell. +4. **Motion is opt-out, never forced.** Quill ships a sensible *default* transition (e.g. + the box expanding upward when a question tool activates) but never mandates it. Consumers + can drop to `transition: none` — or any curve/duration — without fighting the library. + See §2c. --- @@ -132,6 +136,7 @@ wrapper over an `InputGroup` slot so the box, grow, and focus states come for fr | --- | --- | --- | | `state` | `"default" \| "accent" \| "invalid"` | `accent` = the blue bash-mode ring; `invalid` = destructive ring. Default relies on `InputGroup` focus-within. | | `size` | `"default" \| "large"` | forwards to `ComposerBody` max-height | +| `motion` | `"default" \| "none"` | ergonomic sugar for the grow/expand transition (§2c). `default` = quill's default curve; `none` = no transition. Never forces animation — just sets the underlying custom property / `data-motion` attribute, which the consumer can also set directly. | | `onFocusRequest` | `() => void` | click on empty chrome → code focuses its editor (quill has no editor ref) | | `className`, `...div` | | passthrough | @@ -174,6 +179,55 @@ variant on `Item`) that renders the option row with a radio/checkbox slot, descr Open call for review: this may be light enough that we keep the whole `ActionSelector` option-row rendering in `code` and only ship `Composer`. See Open Questions Q2. +### 2c. Motion policy — defaults, not mandates + +The most visible motion is the box **growing upward** when a question tool activates +(default → question mode) and shrinking back. Quill should own a *good default* for this +but must never impose it — a consumer that wants an instant, non-animated expand (or a +different curve) must get there without `!important` battles or re-implementing the shell. + +**Mechanism (matches quill's existing tunable pattern, e.g. `--quill-shimmer-base`):** + +1. The transition is declared against a **CSS custom property with a built-in default**, + not a hardcoded literal: + + ```css + .quill-composer { + /* grow/shrink of the block-addon stack; height:auto isn't natively animatable, so + the implementation uses the grid-rows 0fr↔1fr (or interpolate-size) technique on + the addon wrapper — the point here is the *transition* is overridable. */ + transition: var(--quill-composer-motion, grid-template-rows 150ms ease, height 150ms ease); + } + ``` + +2. Consumers override at any level, no library fork: + + ```css + /* kill it everywhere */ + :root { --quill-composer-motion: none; } + /* or per-instance */ + /* sets --quill-composer-motion: none */ + + ``` + +3. `prefers-reduced-motion: reduce` collapses the default to `none` automatically — but an + explicit consumer value still wins, so reduced-motion is a floor, not an override of intent. + +**Rules this encodes:** + +- Quill never writes a bare `transition:` the consumer can't reach. Every animated property + routes through a custom property with a default, so "go to `transition: none`" is a + one-line override, not a fight. +- The `motion` prop is *only* sugar over that property — the CSS custom property and + `data-motion` attribute remain the source of truth, so app-wide theming (set the property + once at the root) and per-instance opt-out both work. +- Same policy applies to any other Composer motion we add (submit-button state cross-fade, + focus ring). Default provided, always overridable, reduced-motion respected. +- This does **not** change today's behavior on the InputGroup primitive itself, which only + transitions `color`/`background`/`border` and grows instantly — the Composer *adds* an + opt-out-able grow transition on top; leaving `motion` unset on a plain `InputGroup` keeps + the current instant grow. + --- ## 3. The question surface — continuity at the bottom @@ -204,6 +258,8 @@ Composer ← same shell, same position, grows - The **options render in `ComposerHeader`** (block-start addon) so the box grows upward from the pinned bottom — nothing below moves, the input appears to stay put and expand. + The grow uses the default, overridable transition from §2c (a consumer can set + `motion="none"` for an instant expand). - The **body stays a live editor** as the "talk about it" / free-text / "Other" answer. Typing + Enter there submits a discussion message (or the custom answer); the toolbar's **Submit answer** commits the selected option(s). This is exactly the "becomes the talk @@ -299,6 +355,11 @@ Steps 3 and 4 are independently shippable; 4 depends on 3. - **Q5 — `onFocusRequest`.** Quill can't hold the editor ref, so click-on-chrome-to-focus is surfaced as a callback. Acceptable seam? - **Q6 — `EditorHandle`.** Stays entirely in `code`; quill exposes no imperative handle. Confirm. +- **Q7 — shipped motion default.** The grow transition is always overridable (§2c); the + question is what quill ships as the *default* — a subtle animated expand, or `none` (motion + strictly opt-in per instance)? Recommendation: ship a subtle default expand + honor + reduced-motion, since the continuity effect is the point; `code` can still set `motion="none"` + anywhere it wants instant. Confirm. ---