From 89d0348127a84fd8ccefc51222b8acf37b184932 Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Sat, 22 Aug 2026 00:48:53 +0300 Subject: [PATCH 1/5] feat(chat)!: generic requests replace the ask-user questions/answers API --- .changeset/generic-requests.md | 23 ++ AGENTS.md | 2 +- CHAT_ARCHITECTURE.md | 52 ++-- COMPOSER.md | 34 +-- MESSAGE.md | 2 - components/ai/ask-user.tsx | 261 ------------------ components/ai/ask.tsx | 205 ++++++++++++++ components/ai/composer.tsx | 122 ++++---- components/chat.tsx | 52 ++-- content/docs/build-a-chat.mdx | 2 +- content/docs/handbook/accessibility.mdx | 4 +- content/docs/handbook/styling.mdx | 4 +- content/docs/index.mdx | 4 +- content/docs/primitives/composer.mdx | 69 +++-- .../composer/demos/approval-flow.tsx | 221 +++++++++++++++ .../demos/{ask-user-flow.tsx => ask-flow.tsx} | 48 ++-- .../composer/demos/{ask-user.tsx => ask.tsx} | 104 +++---- content/docs/quick-start.mdx | 2 +- lib/ai/types.ts | 10 +- next.config.ts | 2 +- packages/ui/README.md | 4 +- packages/ui/package.json | 6 +- packages/ui/src/ask-user/index.parts.ts | 21 -- packages/ui/src/ask-user/index.ts | 24 -- .../{ask-user/ask-user.tsx => ask/ask.tsx} | 174 ++++++------ packages/ui/src/ask/index.parts.ts | 21 ++ packages/ui/src/ask/index.ts | 24 ++ packages/ui/src/composer/actions.tsx | 2 +- packages/ui/src/composer/index.ts | 13 +- packages/ui/src/composer/keyboard.ts | 32 +-- packages/ui/src/composer/panel.tsx | 2 +- ...ask-user-machine.ts => request-machine.ts} | 172 ++++++------ packages/ui/src/composer/root.tsx | 43 +-- packages/ui/src/composer/store.ts | 176 ++++++------ packages/ui/src/composer/textarea.tsx | 2 +- packages/ui/src/composer/types.ts | 41 +-- .../ui/src/composer/use-composer-editor.tsx | 24 +- packages/ui/src/message-utils.ts | 2 +- packages/ui/src/thread/geometry.ts | 2 +- packages/ui/src/types.ts | 4 - ...k-user.a11y.test.tsx => ask.a11y.test.tsx} | 76 ++--- .../tests/composer-ask-user-machine.test.ts | 133 --------- packages/ui/tests/composer-escape.test.tsx | 42 +-- packages/ui/tests/composer-keyboard.test.ts | 42 +-- .../ui/tests/composer-request-machine.test.ts | 175 ++++++++++++ .../composer-scroll-containment.test.tsx | 48 ++-- .../ui/tests/composer-store-present.test.ts | 16 +- 47 files changed, 1396 insertions(+), 1148 deletions(-) create mode 100644 .changeset/generic-requests.md delete mode 100644 components/ai/ask-user.tsx create mode 100644 components/ai/ask.tsx create mode 100644 content/docs/primitives/composer/demos/approval-flow.tsx rename content/docs/primitives/composer/demos/{ask-user-flow.tsx => ask-flow.tsx} (72%) rename content/docs/primitives/composer/demos/{ask-user.tsx => ask.tsx} (50%) delete mode 100644 packages/ui/src/ask-user/index.parts.ts delete mode 100644 packages/ui/src/ask-user/index.ts rename packages/ui/src/{ask-user/ask-user.tsx => ask/ask.tsx} (72%) create mode 100644 packages/ui/src/ask/index.parts.ts create mode 100644 packages/ui/src/ask/index.ts rename packages/ui/src/composer/{ask-user-machine.ts => request-machine.ts} (50%) rename packages/ui/tests/a11y/{ask-user.a11y.test.tsx => ask.a11y.test.tsx} (74%) delete mode 100644 packages/ui/tests/composer-ask-user-machine.test.ts create mode 100644 packages/ui/tests/composer-request-machine.test.ts diff --git a/.changeset/generic-requests.md b/.changeset/generic-requests.md new file mode 100644 index 00000000..1b66e5ec --- /dev/null +++ b/.changeset/generic-requests.md @@ -0,0 +1,23 @@ +--- +"@intentface/ui": minor +--- + +The ask-user flow is now a generic request flow: consumer-minted ids and option values replace display-text identity, so tool approvals (and any future choice put to the user) need no new types. One term throughout — requests in, resolved requests back. + +Migration: + +| Before | After | +| --- | --- | +| prop `questions` | `requests` (entries now require an `id` you mint) | +| `AskUserQuestion` | `ComposerRequest`; field `question` → `label` | +| `AskUserOption` | `ComposerRequestOption` (gains optional `value`, echoed back; falls back to `label`) | +| `ComposerAnswerEntry` (4-variant union) | `ComposerRequestEntry` — flat `{ id, selected: string[], text? }`; `text` present only when typed; skipped entries have `selected: []` | +| `ComposerAnswersSubmit`, `kind: "answers"`, `data.answers` | `ComposerRequestsSubmit`, `kind: "requests"`, `data.requests` | +| `ComposerAskUserState`, slice `composer.askUser` | `ComposerRequestsState`, slice `composer.requests` (fields `questions` → `items`, `answers` → `drafts: Map`) | +| store `setQuestions` / `activateAskUser` / `submitAnswersRef` | `setRequests` / `activateRequests` / `submitRequestsRef` | +| `interpretAskUserKey` | `interpretRequestKey` | +| `EditorKeyContext.hasActiveAskUser` | `hasActiveRequests` | +| `EditorKeyAction` `"ask-user-arrow"` / `"ask-user-dismiss"` | `"request-arrow"` / `"request-dismiss"` | +| subpath `@intentface/ui/ask-user` | `@intentface/ui/ask` | +| namespace `AskUser.*`, hooks `useAskUserOption(s)`, `AskUser*Props` | `Ask.*`, `useAskOption(s)`, `Ask*Props` | +| attributes `data-ask-user-*` | `data-ask-*` | diff --git a/AGENTS.md b/AGENTS.md index 33b2f330..a19db30e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Two constraints follow, and both are load-bearing: - **`index.ts` and `index.parts.ts` must never carry `"use client"`.** The directive belongs on the component module one level down. Adding it to either barrel silently reintroduces the bug. - **The build must not bundle.** One file gets one top-level directive, so bundling collapses the boundary. `tsconfig.build.json` emits per-module via tsc for exactly this reason — see the comment there before changing it. -This pattern is used throughout: `Message`, `Composer`, `Attachments`, `Chip`, `Thread`, `Steps`, `Reasoning`, `AskUser`. +This pattern is used throughout: `Message`, `Composer`, `Attachments`, `Chip`, `Thread`, `Steps`, `Reasoning`, `Ask`. A change to the package needs a changeset, and a changeset is one short line per change, not a write-up: see [`.changeset/README.md`](./.changeset/README.md). The reasoning goes in the PR body. diff --git a/CHAT_ARCHITECTURE.md b/CHAT_ARCHITECTURE.md index c5c04105..874259f8 100644 --- a/CHAT_ARCHITECTURE.md +++ b/CHAT_ARCHITECTURE.md @@ -62,7 +62,7 @@ export const Composer = Object.assign(ComposerRoot, { Container, Attachments, AttachmentTrigger, ContextWindow, Actions, Placeholder, Submit, Panel, PanelItem, Textarea, - AskUser, AskUserHints, AskUserDismiss, AskUserContinue, + Ask, AskHints, AskDismiss, AskContinue, Commands, CommandList, CommandItems, CommandLoading, CommandEmpty, CommandDismiss, CommandItem, CommandItemIcon, CommandItemLabel, CommandItemDescription, CommandGroup, CommandGroupLabel, CommandCollection, @@ -88,7 +88,7 @@ The nesting hierarchy at a glance: - + @@ -100,9 +100,9 @@ The nesting hierarchy at a glance: - - - + + + @@ -118,7 +118,7 @@ export type ComposerRootProps = Omit, "onSubmit" | "ref"> onSubmit?: (data: ComposerSubmitData) => void | Promise; isSubmitting?: boolean; commands?: ComposerCommandsMap; - questions?: AskUserQuestion[]; // present → drives the ask-user flow + requests?: ComposerRequest[]; // present → drives the request flow defaultValue?: ComposerSnapshot; // uncontrolled editor doc value?: ComposerSnapshot; // controlled editor doc onValueChange?: (snapshot: ComposerSnapshot) => void; @@ -136,13 +136,13 @@ type ComposerMessageSubmit = { files: FileUIPart[]; }; -type ComposerAnswersSubmit = { - kind: "answers"; - answers: ComposerAnswerEntry[]; // emitted by the ask-user flow +type ComposerRequestsSubmit = { + kind: "requests"; + requests: ComposerRequestEntry[]; // emitted by the request flow }; ``` -Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.sendMessage`, an `"answers"` submit goes to `addToolOutput` (resolving the open `askUser` tool call). +Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.sendMessage`, a `"requests"` submit goes to `addToolOutput` (resolving the open `askUser` tool call). **`ComposerSnapshot`** is an opaque, branded wrapper around the editor's paragraph JSON (`{ __doc, __brand }`) used by the controlled `value` / `defaultValue` API — distinct from `Composer.Textarea`'s plain-string `value`. Treat it as a token: persist it and hand it back, but don't read into `__doc`. @@ -151,14 +151,14 @@ Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.se A selector hook over a module-singleton store (`useSyncExternalStore`); no context needed. Slices are identity-stable — a slice's reference changes only when its data does. ```ts -const { textarea, attachments, askUser, panel, commands, isSubmitting } = useComposer(); +const { textarea, attachments, requests, panel, commands, isSubmitting } = useComposer(); ``` | Slice | Shape | | ------------- | --------------------------------------------------------------------------- | | `textarea` | The editor controller (`focus/blur/clear/insertText/insertChip/getText/setText/serialize/ensureFocus`) plus reactive `hasContent` | | `attachments` | `{ items, add, remove, openFileDialog, error, isDragging, fileInputRef, … }` | -| `askUser` | `{ questions, step, answers, toggleOption, continueStep, dismissStep, isLastStep, isSingle, goBack, goNext, … }` | +| `requests` | `{ items, step, drafts, toggleOption, continueStep, dismissStep, isLastStep, isSingle, goBack, goNext, … }` | | `panel` | `{ isOpen, value }` — which panel item is open | | `commands` | `{ isOpen, trigger, query }` — prefix-popover state | | `isSubmitting`| Boolean mirror of the root's `isSubmitting` prop | @@ -186,22 +186,22 @@ The same controller surface is also exported as the module singleton **`composer … … - + ``` The matched item animates in (spring height via `useMeasure`, blur-in); only one is visible at a time. -### Ask-user — `Composer.AskUser` / `AskUserHints` / `AskUserDismiss` / `AskUserContinue` +### Requests — `Composer.Ask` / `AskHints` / `AskDismiss` / `AskContinue` -Active when the root receives a non-empty `questions` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `askUser` store slice. +Active when the root receives a non-empty `requests` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `requests` store slice. -- `Composer.AskUser` — renders the current question with its options and a free-text fallback; handles single- and multi-select, plus prev/next navigation across questions. -- `Composer.AskUserHints` — keyboard-hint pills (↑↓ navigate, ↵ select, ←→ between questions, esc skip). -- `Composer.AskUserDismiss` — skips the current question (`askUser.dismissStep`). -- `Composer.AskUserContinue` — submits the form; labeled `"Continue"`, or `"Submit"` on the last step. The form handler routes it through `askUser.continueStep`, which compiles per-question answers into the `ComposerAnswerEntry` union and fires `onSubmit({ kind: "answers", answers })`. +- `Composer.Ask` — renders the current question with its options and a free-text fallback; handles single- and multi-select, plus prev/next navigation across questions. +- `Composer.AskHints` — keyboard-hint pills (↑↓ navigate, ↵ select, ←→ between questions, esc skip). +- `Composer.AskDismiss` — skips the current question (`requests.dismissStep`). +- `Composer.AskContinue` — submits the form; labeled `"Continue"`, or `"Submit"` on the last step. The form handler routes it through `requests.continueStep`, which compiles per-request entries (flat `ComposerRequestEntry`) and fires `onSubmit({ kind: "requests", requests })`. -When in ask-user mode, swap the `Actions` row from the standard layout to ` `. +When in request mode, swap the `Actions` row from the standard layout to ` `. ### Commands — prefix-triggered popovers @@ -269,7 +269,7 @@ The `variant`/`icon` ride in the query string, so the token carries everything n ### Submission flow 1. `Composer.Submit` (or Enter in the editor) triggers form submit. -2. If `questions` is active and the user is mid-flow → `askUser.continueStep()` advances or finalises (emitting `{ kind: "answers" }`). +2. If `requests` is active and the user is mid-flow → `requests.continueStep()` advances or finalises (emitting `{ kind: "requests" }`). 3. Otherwise → `serializeEditorContent` produces `{ text }` (chips already inlined), attachments become `FileUIPart[]`, the editor and attachments reset, and the root calls `onSubmit({ kind: "message", text, files })`. No `chips` or `tools` field — chips live in `text`, tool toggles live in the consumer. 4. The parent maps that to `chat.sendMessage({ parts: [...files, { type: "text", text }] }, { body: { webSearch, thinking } })`. @@ -475,7 +475,7 @@ A function-over-state hook that decides what the panel above the composer should type ComposerPanelState = | { type: "idle" } | { type: "active"; steps: ComposerStepItem[] } - | { type: "ask-user"; toolCallId: string; questions: AskUserQuestion[]; isAnswered: boolean }; + | { type: "ask-user"; toolCallId: string; questions: ComposerRequest[]; isAnswered: boolean }; ``` Logic, in order: @@ -597,7 +597,7 @@ const ChatSurface = ({ chatId }: { chatId: string }) => { {/* CommandLists */} {/* StepQueue */} - + @@ -608,9 +608,9 @@ const ChatSurface = ({ chatId }: { chatId: string }) => { {panelState.type === "ask-user" ? ( <> - - - + + + ) : ( diff --git a/COMPOSER.md b/COMPOSER.md index 8e009a48..3d53eb3f 100644 --- a/COMPOSER.md +++ b/COMPOSER.md @@ -43,11 +43,11 @@ type ComposerSubmitData = chips: ChipData[]; // inline mention chips } | { - kind: "answers"; - answers: ComposerAnswerEntry[]; // produced by the questionnaire flow + kind: "requests"; + answers: ComposerRequestEntry[]; // produced by the questionnaire flow }; -type ComposerAnswerEntry = +type ComposerRequestEntry = | { question: string; option: string } // single-select, picked an option | { question: string; text: string } // single-select, typed free text | { question: string; options: string[]; text: string } // multi-select (either field may be empty) @@ -67,7 +67,7 @@ If the editor is empty but attachments exist, `text` is set to `"Sent with attac | `onSubmit` | `(data: ComposerSubmitData) => void \| Promise` | Submit handler. | | `isSubmitting` | `boolean` | Disables `Composer.Submit` while truthy. Default `false`. | | `commands` | `ComposerCommandsMap` | Prefix → command-list config. See [Commands & chips](#commands--chips). | -| `questions` | `AskUserQuestion[]` | When present, the composer enters questionnaire mode. See [Questionnaire](#questionnaire). | +| `questions` | `ComposerRequest[]` | When present, the composer enters questionnaire mode. See [Questionnaire](#questionnaire). | | `defaultValue` | `ComposerSnapshot` | Initial uncontrolled editor content. | | `value` | `ComposerSnapshot` | Controlled editor content. | | `onValueChange` | `(snapshot) => void` | Fires on editor change. | @@ -155,7 +155,7 @@ The panel is the area above the editor that shows command lists, active tool pro - + ``` @@ -266,10 +266,10 @@ The default `` renderer already wires this up. Pass `questions` to switch the composer into structured-question mode. The default renderer covers the flow: ```tsx - + - + @@ -277,23 +277,23 @@ Pass `questions` to switch the composer into structured-question mode. The defau - - - + + + ``` Parts: -- `Composer.AskUser` — full default UI (question text, options, step counter, nav arrows). -- `Composer.AskUserHints` — keyboard-hint footer (↑↓ ↵ ← → Esc). -- `Composer.AskUserDismiss` — skip the current question (`Esc`). -- `Composer.AskUserContinue` — advance / submit (`Enter`). Auto-toggles label between "Continue" and "Submit". +- `Composer.Ask` — full default UI (question text, options, step counter, nav arrows). +- `Composer.AskHints` — keyboard-hint footer (↑↓ ↵ ← → Esc). +- `Composer.AskDismiss` — skip the current question (`Esc`). +- `Composer.AskContinue` — advance / submit (`Enter`). Auto-toggles label between "Continue" and "Submit". Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between questions, **Esc** dismiss, printable keys type free-text. -On completion, `onSubmit` fires with `{ kind: "answers", answers }` where `answers` is a `ComposerAnswerEntry[]` (one entry per question, in order). See [Submit data](#submit-data) for the entry shape. +On completion, `onSubmit` fires with `{ kind: "requests", requests }` where `answers` is a `ComposerRequestEntry[]` (one entry per question, in order). See [Submit data](#submit-data) for the entry shape. ## Imperative API & state @@ -302,7 +302,7 @@ There is no root `ref` handle. Editor content is controlled declaratively via `v For live state, read from the store with `useComposer(selector)`. It's a module singleton — no provider — so anything in the subtree, a toolbar, or a sibling panel can subscribe. A selector re-renders only when that slice changes identity: ```tsx -const askUser = useComposer((composer) => composer.askUser); // questionnaire machine + actions +const requests = useComposer((composer) => composer.requests); // request machine + actions const attachments = useComposer((composer) => composer.attachments); // add, remove, openFileDialog, items, error const commands = useComposer((composer) => composer.commands); // open command-list state (isOpen, trigger, query) ``` @@ -325,7 +325,7 @@ const items: CommandItemData[] = [ Exported from `@/components/ai/composer`: -`ComposerEditorHandle`, `ComposerSnapshot`, `ComposerSubmitData`, `ComposerMessageSubmit`, `ComposerAnswersSubmit`, `ComposerAnswerEntry`, `ComposerCommandsMap`, `ComposerCommandsConfig`, `ComposerCommandsItems`, `CommandItemData`, `CommandItemKind`, `PrefixOnSelectContext`, `TriggerRule`, `ChipData`, `AttachmentsApi`. +`ComposerEditorHandle`, `ComposerSnapshot`, `ComposerSubmitData`, `ComposerMessageSubmit`, `ComposerRequestsSubmit`, `ComposerRequestEntry`, `ComposerCommandsMap`, `ComposerCommandsConfig`, `ComposerCommandsItems`, `CommandItemData`, `CommandItemKind`, `PrefixOnSelectContext`, `TriggerRule`, `ChipData`, `AttachmentsApi`. `ChipVariant` is re-used from `@/components/ai/chip`. diff --git a/MESSAGE.md b/MESSAGE.md index d3520b61..75d7ab89 100644 --- a/MESSAGE.md +++ b/MESSAGE.md @@ -99,7 +99,6 @@ Renders an assistant turn's reasoning + tool activity as a collapsible timeline {markdown} - ; ``` @@ -112,7 +111,6 @@ Parts: - **`Steps.Body`** — markdown inside a step. `Markdown` props. - **`Steps.Summary`** — compact result text in a step. `span` props. - **`Steps.ToolCall`** — renders a tool invocation from `{ part: ToolPart }` (auto-derives label/status/summary/sources). -- **`Steps.AskUser`** — renders an answered ask-user exchange from `{ part: ToolPart }`. - **`Steps.SearchResults`** / **`Steps.SearchResult`** — search-result badges. ## Reasoning diff --git a/components/ai/ask-user.tsx b/components/ai/ask-user.tsx deleted file mode 100644 index e7bec5c2..00000000 --- a/components/ai/ask-user.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import { - type AskUserOptionsHandle, - AskUser as AskUserPrimitive, - useAskUserOption, - useAskUserOptions, -} from "@intentface/ui/ask-user"; -import { - Check, - ChevronDown, - ChevronLeft, - ChevronRight, - CircleQuestion, -} from "@keyline-icons/react"; -import { type ComponentProps, type RefObject, useState } from "react"; -import { Collapsible } from "@/components/ui/collapsible"; -import type { AskUserQuestion } from "@/lib/ai/types"; -import { cn } from "@/lib/utils"; - -export type { AskUserOptionsHandle }; - -/** AskUser root container. Stateless — consumers manage all state externally. */ -type AskUserRootProps = ComponentProps; - -const AskUserRoot = ({ className, ...props }: AskUserRootProps) => ( - -); - -/** Question heading text. */ -type AskUserLabelProps = ComponentProps; - -const AskUserLabel = ({ className, ...props }: AskUserLabelProps) => ( - -); - -/** Row container for `Label` and optional `Navigation`. */ -type AskUserHeaderProps = ComponentProps; - -const AskUserHeader = ({ className, ...props }: AskUserHeaderProps) => ( - -); - -/** Row container for `Previous`, `StepLabel`, and `Next`. */ -type AskUserNavigationProps = ComponentProps; - -const AskUserNavigation = ({ className, ...props }: AskUserNavigationProps) => ( - -); - -const navigationButtonClasses = - "flex size-6 cursor-pointer items-center justify-center rounded-full text-ink-secondary transition-colors hover:bg-ink-primary/5 hover:text-ink-primary focus-visible:outline-2 focus-visible:outline-accent-bg/60 disabled:pointer-events-none disabled:opacity-30"; - -/** Navigate to the previous step. */ -type AskUserPreviousProps = ComponentProps; - -const AskUserPrevious = ({ className, ...props }: AskUserPreviousProps) => ( - - - -); - -/** Navigate to the next step. */ -type AskUserNextProps = ComponentProps; - -const AskUserNext = ({ className, ...props }: AskUserNextProps) => ( - - - -); - -/** Displays "{current} of {total}" step indicator. Supports custom children to override the default text. */ -type AskUserStepLabelProps = ComponentProps; - -const AskUserStepLabel = ({ className, ...props }: AskUserStepLabelProps) => ( - -); - -/** Fieldset wrapper for `Option` items. The primitive owns the radiogroup/group - * semantics and roving focus; selection flows through Option selected/onSelect. */ -type AskUserOptionsProps = ComponentProps & { - ref?: RefObject; -}; - -const AskUserOptions = ({ multiSelect = false, className, ...props }: AskUserOptionsProps) => ( - -); - -/** Selectable card. Self-registers with parent `Options` on mount (cmdk pattern). */ -type AskUserOptionProps = ComponentProps; - -const AskUserOption = ({ className, ...props }: AskUserOptionProps) => ( - -); - -/** Decorative selection indicator. The Option element itself carries the - * radio/checkbox role and checked state, so this is pure presentation — - * aria-hidden, no id, never focusable (a nested control would be invalid - * inside role=radio and would break the composer's key scoping). */ -const AskUserOptionInput = () => { - const options = useAskUserOptions(); - return options.multiSelect ? : ; -}; - -const INDICATOR_CLASS = cn( - "flex size-4 shrink-0 items-center justify-center rounded-[5px] bg-raised font-medium text-[10px] text-ink-secondary tabular-nums shadow-raised", -); - -const INDICATOR_SELECTED_CLASS = "bg-accent-raised text-white shadow-accent"; - -const AskUserOptionCheckIndicator = () => { - const option = useAskUserOption(); - return ( - - ); -}; - -/** Numbered indicator for single-select options: the item's 1-based index. */ -const AskUserOptionIndexIndicator = () => { - const option = useAskUserOption(); - const { items } = useAskUserOptions(); - const index = items.current.indexOf(option.value) + 1; - return ( - - ); -}; - -/** Flex column wrapper for `OptionLabel` and `OptionDescription`. */ -type AskUserOptionContentProps = ComponentProps; - -const AskUserOptionContent = ({ className, ...props }: AskUserOptionContentProps) => ( - -); - -/** Option title text. */ -type AskUserOptionLabelProps = ComponentProps; - -const AskUserOptionLabel = ({ className, ...props }: AskUserOptionLabelProps) => ( - -); - -/** Option subtitle/description text. */ -type AskUserOptionDescriptionProps = ComponentProps; - -const AskUserOptionDescription = ({ className, ...props }: AskUserOptionDescriptionProps) => ( - -); - -/** Collapsible read-only summary of answered questions. Used for completed tool calls. */ -type AskUserSummaryProps = ComponentProps & { - questions: AskUserQuestion[]; - answers: Record; -}; - -const AskUserSummary = ({ questions, answers, className, ...props }: AskUserSummaryProps) => { - const [isOpen, setIsOpen] = useState(false); - const count = questions.length; - - return ( - - - - Answered {count} {count === 1 ? "question" : "questions"} - - - - - {questions.map((q) => ( -
-
- -
-
- {q.question} - - {answers[q.question] ?? "—"} - -
-
- ))} -
-
- ); -}; - -/** Keyboard shortcut hints displayed below the ask-user options. */ -type AskUserHintsProps = ComponentProps; - -const AskUserHints = ({ className, ...props }: AskUserHintsProps) => ( - -); - -export const AskUser = Object.assign(AskUserRoot, { - Header: AskUserHeader, - Label: AskUserLabel, - Navigation: AskUserNavigation, - Previous: AskUserPrevious, - Next: AskUserNext, - StepLabel: AskUserStepLabel, - Options: AskUserOptions, - Option: AskUserOption, - OptionInput: AskUserOptionInput, - OptionContent: AskUserOptionContent, - OptionLabel: AskUserOptionLabel, - OptionDescription: AskUserOptionDescription, - Summary: AskUserSummary, - Hints: AskUserHints, -}); diff --git a/components/ai/ask.tsx b/components/ai/ask.tsx new file mode 100644 index 00000000..b572974c --- /dev/null +++ b/components/ai/ask.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { + type AskOptionsHandle, + Ask as AskPrimitive, + useAskOption, + useAskOptions, +} from "@intentface/ui/ask"; +import { Check, ChevronLeft, ChevronRight } from "@keyline-icons/react"; +import type { ComponentProps, RefObject } from "react"; +import { cn } from "@/lib/utils"; + +export type { AskOptionsHandle }; + +/** Ask root container. Stateless — consumers manage all state externally. */ +type AskRootProps = ComponentProps; + +const AskRoot = ({ className, ...props }: AskRootProps) => ( + +); + +/** Request label text. */ +type AskLabelProps = ComponentProps; + +const AskLabel = ({ className, ...props }: AskLabelProps) => ( + +); + +/** Row container for `Label` and optional `Navigation`. */ +type AskHeaderProps = ComponentProps; + +const AskHeader = ({ className, ...props }: AskHeaderProps) => ( + +); + +/** Row container for `Previous`, `StepLabel`, and `Next`. */ +type AskNavigationProps = ComponentProps; + +const AskNavigation = ({ className, ...props }: AskNavigationProps) => ( + +); + +const navigationButtonClasses = + "flex size-6 cursor-pointer items-center justify-center rounded-full text-ink-secondary transition-colors hover:bg-ink-primary/5 hover:text-ink-primary focus-visible:outline-2 focus-visible:outline-accent-bg/60 disabled:pointer-events-none disabled:opacity-30"; + +/** Navigate to the previous step. */ +type AskPreviousProps = ComponentProps; + +const AskPrevious = ({ className, ...props }: AskPreviousProps) => ( + + + +); + +/** Navigate to the next step. */ +type AskNextProps = ComponentProps; + +const AskNext = ({ className, ...props }: AskNextProps) => ( + + + +); + +/** Displays "{current} of {total}" step indicator. Supports custom children to override the default text. */ +type AskStepLabelProps = ComponentProps; + +const AskStepLabel = ({ className, ...props }: AskStepLabelProps) => ( + +); + +/** Fieldset wrapper for `Option` items. The primitive owns the radiogroup/group + * semantics and roving focus; selection flows through Option selected/onSelect. */ +type AskOptionsProps = ComponentProps & { + ref?: RefObject; +}; + +const AskOptions = ({ multiSelect = false, className, ...props }: AskOptionsProps) => ( + +); + +/** Selectable card. Self-registers with parent `Options` on mount (cmdk pattern). */ +type AskOptionProps = ComponentProps; + +const AskOption = ({ className, ...props }: AskOptionProps) => ( + +); + +/** Decorative selection indicator. The Option element itself carries the + * radio/checkbox role and checked state, so this is pure presentation — + * aria-hidden, no id, never focusable (a nested control would be invalid + * inside role=radio and would break the composer's key scoping). */ +const AskOptionInput = () => { + const options = useAskOptions(); + return options.multiSelect ? : ; +}; + +const INDICATOR_CLASS = cn( + "flex size-4 shrink-0 items-center justify-center rounded-[5px] bg-raised font-medium text-[10px] text-ink-secondary tabular-nums shadow-raised", +); + +const INDICATOR_SELECTED_CLASS = "bg-accent-raised text-white shadow-accent"; + +const AskOptionCheckIndicator = () => { + const option = useAskOption(); + return ( + + ); +}; + +/** Numbered indicator for single-select options: the item's 1-based index. */ +const AskOptionIndexIndicator = () => { + const option = useAskOption(); + const { items } = useAskOptions(); + const index = items.current.indexOf(option.value) + 1; + return ( + + ); +}; + +/** Flex column wrapper for `OptionLabel` and `OptionDescription`. */ +type AskOptionContentProps = ComponentProps; + +const AskOptionContent = ({ className, ...props }: AskOptionContentProps) => ( + +); + +/** Option title text. */ +type AskOptionLabelProps = ComponentProps; + +const AskOptionLabel = ({ className, ...props }: AskOptionLabelProps) => ( + +); + +/** Option subtitle/description text. */ +type AskOptionDescriptionProps = ComponentProps; + +const AskOptionDescription = ({ className, ...props }: AskOptionDescriptionProps) => ( + +); + +/** Keyboard shortcut hints displayed below the ask options. */ +type AskHintsProps = ComponentProps; + +const AskHints = ({ className, ...props }: AskHintsProps) => ( + +); + +export const Ask = Object.assign(AskRoot, { + Header: AskHeader, + Label: AskLabel, + Navigation: AskNavigation, + Previous: AskPrevious, + Next: AskNext, + StepLabel: AskStepLabel, + Options: AskOptions, + Option: AskOption, + OptionInput: AskOptionInput, + OptionContent: AskOptionContent, + OptionLabel: AskOptionLabel, + OptionDescription: AskOptionDescription, + Hints: AskHints, +}); diff --git a/components/ai/composer.tsx b/components/ai/composer.tsx index c99d0360..7482a1e0 100644 --- a/components/ai/composer.tsx +++ b/components/ai/composer.tsx @@ -19,7 +19,7 @@ import { import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from "react"; -import { AskUser } from "@/components/ai/ask-user"; +import { Ask } from "@/components/ai/ask"; import { Attachments } from "@/components/ai/attachments"; import { Chip } from "@/components/ai/chip"; import Button from "@/components/ui/button"; @@ -47,14 +47,16 @@ export type { AttachmentsApi, ChipData, CommandItemKind, - ComposerAnswerEntry, - ComposerAnswersSubmit, ComposerCommandsConfig, ComposerCommandsItems, ComposerCommandsMap, ComposerEditorHandle, ComposerEditorState, ComposerMessageSubmit, + ComposerRequest, + ComposerRequestEntry, + ComposerRequestOption, + ComposerRequestsSubmit, ComposerSnapshot, ComposerSubmitData, ComposerSubmitOn, @@ -586,78 +588,78 @@ const ComposerCommands = ({ prefix }: ComposerCommandsProps) => ( ); // --------------------------------------------------------------------------- -// AskUser (with sub-Parts) — default render for the ask-user flow registered -// via the `questions` prop on Composer Root. +// Ask (with sub-Parts) — default render for the request flow registered +// via the `requests` prop on Composer Root. // --------------------------------------------------------------------------- -const ComposerAskUser = () => { - const askUser = useComposer((composer) => composer.askUser); +const ComposerAsk = () => { + const requests = useComposer((composer) => composer.requests); - // Content only. The consumer gates the enclosing Panel on `askUser.active`; - // this renders the question compound, or null when there are none. `display` - // keeps the last question on screen through an exit animation. - const question = askUser.questions?.[askUser.step] ?? null; - const lastQuestionRef = useRef(question); - if (question) lastQuestionRef.current = question; - const display = question ?? lastQuestionRef.current; + // Content only. The consumer gates the enclosing Panel on `requests.active`; + // this renders the request compound, or null when there are none. `display` + // keeps the last request on screen through an exit animation. + const request = requests.items?.[requests.step] ?? null; + const lastRequestRef = useRef(request); + if (request) lastRequestRef.current = request; + const display = request ?? lastRequestRef.current; if (!display) return null; - const entry = askUser.answers.get(askUser.step) ?? { + const entry = requests.drafts.get(requests.step) ?? { selected: new Set(), freeText: "", }; - const totalQuestions = askUser.questions?.length ?? 0; + const totalRequests = requests.items?.length ?? 0; return ( - - - {display.question} - {!askUser.isSingle && totalQuestions > 1 && ( - - - + + + {display.label} + {!requests.isSingle && totalRequests > 1 && ( + + + {({ current, total }) => `${current} of ${total} questions`} - - - + + + )} - + {display.options && ( - {display.options.map((option) => ( - askUser.toggleOption(option.label)} + onSelect={() => requests.toggleOption(option.label)} > - - - {option.label} + + + {option.label} {option.description && ( - {option.description} + {option.description} )} - - + + ))} - + )} - + ); }; -const ComposerAskUserHints = ({ className, ...props }: ComponentProps) => { - const askUser = useComposer((composer) => composer.askUser); - const totalQuestions = askUser.questions?.length ?? 0; +const ComposerAskHints = ({ className, ...props }: ComponentProps) => { + const requests = useComposer((composer) => composer.requests); + const totalRequests = requests.items?.length ?? 0; return ( - + ↑ ↓ navigate @@ -665,7 +667,7 @@ const ComposerAskUserHints = ({ className, ...props }: ComponentProps ↵ select - {!askUser.isSingle && totalQuestions > 1 && ( + {!requests.isSingle && totalRequests > 1 && ( ← → between questions @@ -674,21 +676,21 @@ const ComposerAskUserHints = ({ className, ...props }: ComponentProps esc skip - + ); }; -type ComposerAskUserDismissProps = ComponentProps; +type ComposerAskDismissProps = ComponentProps; -const ComposerAskUserDismiss = ({ className, ...props }: ComposerAskUserDismissProps) => { - const askUser = useComposer((composer) => composer.askUser); +const ComposerAskDismiss = ({ className, ...props }: ComposerAskDismissProps) => { + const requests = useComposer((composer) => composer.requests); return ( ); }; @@ -729,10 +731,10 @@ export const Composer = Object.assign(ComposerRoot, { Panel: ComposerPanel, Popover: ComposerPopover, Textarea: ComposerTextarea, - AskUser: ComposerAskUser, - AskUserHints: ComposerAskUserHints, - AskUserDismiss: ComposerAskUserDismiss, - AskUserContinue: ComposerAskUserContinue, + Ask: ComposerAsk, + AskHints: ComposerAskHints, + AskDismiss: ComposerAskDismiss, + AskContinue: ComposerAskContinue, Commands: ComposerCommands, Command: ComposerCommand, CommandList: ComposerCommandList, diff --git a/components/chat.tsx b/components/chat.tsx index e0f719cf..36a8fc8b 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -41,6 +41,7 @@ import { type CommandItemData, Composer, type ComposerCommandsMap, + type ComposerRequest, type ComposerSubmitData, } from "@/components/ai/composer"; import { GeneratedUi } from "@/components/ai/generated-ui"; @@ -831,27 +832,43 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { const isAskUser = panelState.type === "ask-user" || demoQuestions != null; const askUserQuestions = panelState.type === "ask-user" ? panelState.questions : null; + // Boundary map: tool questions carry no identity, so positional ids are + // minted here. Memoized on the merged feed — setRequests resets the machine + // on any array identity change, so a fresh .map() per render would wipe + // in-progress drafts. + const activeQuestions = demoQuestions ?? askUserQuestions; + const composerRequests = useMemo( + () => + activeQuestions?.map( + (question, index): ComposerRequest => ({ + id: `q-${index}`, + label: question.question, + options: question.options, + multiSelect: question.multiSelect, + }), + ), + [activeQuestions], + ); + const handleSubmit = useCallback( async (data: ComposerSubmitData) => { - if (data.kind === "answers") { + if (data.kind === "requests") { // Demo questions from the playground card: the flow completing IS the - // demo — discard the answers and clear the injection. + // demo — discard the entries and clear the injection. if (demoQuestions) { setDemoQuestions(null); return; } if (panelState.type !== "ask-user") return; - // Project the composer's per-question entries into a question→answer map — the - // shape the tool output is read back as (getAskUserInfo / getAskUserStepInfo). + // Project the flat entries back into the question→answer map the tool + // output is read as (getAskUserInfo / getAskUserStepInfo). Ids resolve + // through the minted requests; skipped entries stay absent. const answers: Record = {}; - for (const entry of data.answers) { - if ("options" in entry) { - answers[entry.question] = [...entry.options, entry.text].filter(Boolean).join(", "); - } else if ("option" in entry) { - answers[entry.question] = entry.option; - } else if ("text" in entry) { - answers[entry.question] = entry.text; - } + for (const entry of data.requests) { + if (entry.selected.length === 0 && entry.text === undefined) continue; + const label = composerRequests?.find((request) => request.id === entry.id)?.label; + if (!label) continue; + answers[label] = [...entry.selected, entry.text].filter(Boolean).join(", "); } addToolOutput({ tool: "askUser", @@ -912,6 +929,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { sendMessage, addToolOutput, panelState, + composerRequests, toolValues, selections, clearSelections, @@ -925,12 +943,12 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { onSubmit={handleSubmit} isSubmitting={isSending} commands={composerCommands} - questions={demoQuestions ?? askUserQuestions ?? undefined} + requests={composerRequests} > {(composer) => { // One at a time, by priority: an active command list wins, else the - // composer's own ask-user flow. (Tool/step status now lives in the + // composer's own request flow. (Tool/step status now lives in the // assistant message, not the composer.) With the popover surface // selected, the command list lifts out of the panel — ask-user // always stays in-flow. @@ -943,7 +961,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { ); } - if (composer.askUser.active) return ; + if (composer.requests.active) return ; return null; }} @@ -1015,8 +1033,8 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { {isAskUser ? ( - - + + ) : ( diff --git a/content/docs/build-a-chat.mdx b/content/docs/build-a-chat.mdx index a41c40ec..e8c34c80 100644 --- a/content/docs/build-a-chat.mdx +++ b/content/docs/build-a-chat.mdx @@ -117,5 +117,5 @@ adapter — the package's `ChatMessage` is satisfied structurally. ## Next steps - [Composer performance](/primitives/composer#performance) — keeping the composer from re-rendering on every chunk -- [Composer](/primitives/composer) — commands, chips, attachments, and the ask-user flow +- [Composer](/primitives/composer) — commands, chips, attachments, and the request flow - [Steps](/primitives/steps) and [Reasoning](/primitives/reasoning) — surfacing tool calls and thinking diff --git a/content/docs/handbook/accessibility.mdx b/content/docs/handbook/accessibility.mdx index 3dc09393..4cb8a680 100644 --- a/content/docs/handbook/accessibility.mdx +++ b/content/docs/handbook/accessibility.mdx @@ -10,7 +10,7 @@ optional. ## What you have to supply - **Focus indication.** Focus moves correctly; nothing paints it. Style - `:focus-visible` yourself, and remember that in command lists and ask-user + `:focus-visible` yourself, and remember that in command lists and request options the highlight *is* the indicator, so a faint highlight is a missing focus ring rather than a soft hover. - **Contrast.** No colour ships with the package. @@ -64,6 +64,6 @@ rather than failing quietly. Both are argued on the page that makes the choice: ## Testing The package runs an axe-based suite over the composer combobox wiring, the -ask-user flows, the display primitives' status attributes, and accessible names +request flows, the display primitives' status attributes, and accessible names across controls. Automated checks cannot evaluate contrast or focus visibility in your styling, so those need your own pass. diff --git a/content/docs/handbook/styling.mdx b/content/docs/handbook/styling.mdx index c8f0ddaa..1357a215 100644 --- a/content/docs/handbook/styling.mdx +++ b/content/docs/handbook/styling.mdx @@ -9,7 +9,7 @@ ways. ## Data attributes Each part stamps an attribute naming itself (`data-composer-editor`, -`data-ask-user-option`) plus attributes for the state it tracks. This is the +`data-ask-option`) plus attributes for the state it tracks. This is the default way to style state, since CSS selects on it without anything running in JavaScript. @@ -42,7 +42,7 @@ from a boolean, a `data-*` variant does the same job in CSS. Sixteen parts declare state; the rest receive an empty object: `Composer.Root`, `Composer.Textarea`, `Composer.Submit`, `Composer.Panel`, `Composer.Popover`, `Composer.Command`, `Composer.ContextWindow`, `Message.Root`, `Chip.Root`, -`Thread.Overlay`, `AskUser.Option`, `Attachments.Dropzone`, and the collapsible +`Thread.Overlay`, `Ask.Option`, `Attachments.Dropzone`, and the collapsible parts behind `Steps` and `Reasoning`. ## The render prop diff --git a/content/docs/index.mdx b/content/docs/index.mdx index 4a141482..16636621 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -44,14 +44,14 @@ with no adapter. | Entry point | What it covers | | --- | --- | -| `/composer` | Contenteditable editor, inline chips, prefix command lists, attachments, ask-user flow | +| `/composer` | Contenteditable editor, inline chips, prefix command lists, attachments, request flow | | `/thread` | Scroll container: at-bottom detection, auto-follow, dock measurement, prepend restoration | | `/message` | Message parts, turn grouping, chip-segmented text | | `/steps` | Collapsible timeline of a run: reasoning, tool calls and answered questions | | `/reasoning` | Reasoning disclosure that tracks streaming state and elapsed time | | `/chip` | Inline tokens for composer mentions and chips in messages | | `/attachments` | Attachment tray: items, remove, drop zone and file picker | -| `/ask-user` | Parts for the questions a model asks the user | +| `/ask` | Parts for the requests a model makes of the user: questions and tool approvals | | `/shell` | Collapsible, resizable sidebar beside the viewport | | `/nav` | Nav tree with roving focus, typeahead and collapsible groups | | `/tabs` | Closable tabs whose panel renders in the layout or in a popover | diff --git a/content/docs/primitives/composer.mdx b/content/docs/primitives/composer.mdx index f488348e..b70af16c 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -1,13 +1,14 @@ --- title: Composer -description: Rich-text chat input with chips, slash/mention commands, attachments, and an ask-user flow. +description: Rich-text chat input with chips, slash/mention commands, attachments, and a request flow (questions and tool approvals). source: composer --- import { Basic } from './composer/demos/basic'; import { Commands } from './composer/demos/commands'; import { Popover } from './composer/demos/popover'; -import { AskUserFlow } from './composer/demos/ask-user-flow'; +import { ApprovalFlow } from './composer/demos/approval-flow'; +import { AskFlow } from './composer/demos/ask-flow'; import { AttachmentsDemo } from './composer/demos/attachments'; import { Controlled } from './composer/demos/controlled'; import { Store } from './composer/demos/store'; @@ -18,7 +19,7 @@ import { Store } from './composer/demos/store'; - **Chat input** — a hand-rolled contenteditable over a flat segment model: native typing and IME, inline chips, attachments. - **Prefix commands** — type `/`, `@`, or other prefixes to open command lists. -- **Panel** — hosts command results, live steps, or an ask-user prompt above the field. +- **Panel** — hosts command results, live steps, or a request prompt above the field. - **Headless** — behavior lives in `@intentface/ui`; the demos below show one way to style it, and every class in them is yours to change. - **Isolate it from your stream** — see [Why the composer needs isolating](#why-the-composer-needs-isolating). The editor is the most expensive thing on the page to re-render per token. - **Get started** — see [Quick start](/quick-start) to add the package. @@ -59,7 +60,7 @@ The bare nesting — every part is optional except `Composer` and `Container`: `Composer.Panel` takes plain children or a callback receiving the composer state, and shows only while its resolved content is non-empty. Gate each part on -the state it belongs to (`commands.active`, `askUser.active`, …) and the panel +the state it belongs to (`commands.active`, `requests.active`, …) and the panel opens and closes to match — priority is the order of your branches. ## Examples @@ -82,17 +83,25 @@ or the other; the content is identical. } file="primitives/composer/demos/popover.tsx" /> -### Ask-user flow +### Request flow -Setting the `questions` prop — typically from an assistant's clarifying -question — arms the ask-user flow and flips `askUser.active`; compose the -`AskUser` parts from `@intentface/ui/ask-user` inside a `Panel` (or -`Popover`), reading the current step from `useComposer(c => c.askUser)`. The +Setting the `requests` prop — a clarifying question, a tool approval, any +choice you put to the user — arms the request flow and flips `requests.active`; +compose the `Ask` parts from `@intentface/ui/ask` inside a `Panel` (or +`Popover`), reading the current step from `useComposer(c => c.requests)`. The flow steps through each question (single- or multi-select), and answering or -skipping the last one fires `onSubmit` with `{ kind: "answers" }`. Passing a -fresh `questions` array re-arms it from the first step. +skipping the last one fires `onSubmit` with `{ kind: "requests" }` — one flat +entry per request, keyed by the id you minted, carrying option values. Passing +a fresh `requests` array re-arms it from the first step. -} file="primitives/composer/demos/ask-user-flow.tsx" /> +} file="primitives/composer/demos/ask-flow.tsx" /> + +A tool approval is the same mechanism, not a feature: mint the tool call id as +the request id, put your decision vocabulary in the option `value`s, and route +the entry back to the tool in `onSubmit`. A single request mounts no +step-navigation parts. + +} file="primitives/composer/demos/approval-flow.tsx" /> ### Attachments @@ -147,7 +156,7 @@ const ChatInputInner = memo(({ panel, status }: ChatInputInnerProps) => ( )); ``` -`derivePanelState` is yours — what belongs in the panel (an ask-user prompt, a +`derivePanelState` is yours — what belongs in the panel (a request prompt, a status line) is your product's policy. Return a referentially stable value while nothing has changed, so the inner composer bails on every chunk except real panel transitions. @@ -156,7 +165,7 @@ transitions. `Composer.Root` is a `
`, so submission and key handling follow form semantics. The editor interprets keys by mode — a command list being open, or -ask-user being active, takes priority over normal typing. +the request flow being active, takes priority over normal typing. **Normal typing** @@ -179,9 +188,9 @@ export const commandKeys = [ -**Ask-user active** +**Request flow active** -export const askUserKeys = [ +export const requestKeys = [ { keys: "ArrowUp / ArrowDown", description: "Navigate options; up past the first refocuses the editor." }, { keys: "ArrowLeft / ArrowRight", description: "Go to the previous / next question." }, { keys: "Enter", description: "Select the highlighted option." }, @@ -189,7 +198,7 @@ export const askUserKeys = [ { keys: "Any character", description: "Focus the editor and start typing a free-text answer." }, ]; - + While `isGenerating`, `Escape` inside the composer calls `Submit`'s `onStop` (via `useComposerSubmit`): from the editor, a button, or a part a positioned `Panel` @@ -215,9 +224,9 @@ The `` that owns submission, store resolution, drag-and-drop, and the prop → store bridges. Renders `data-composer-root`. export const rootProps = [ - { name: "onSubmit", type: "(data: ComposerSubmitData) => void | Promise", description: "Fires on message submit and on ask-user answers. Discriminate on data.kind." }, + { name: "onSubmit", type: "(data: ComposerSubmitData) => void | Promise", description: "Fires on message submit and on request entries. Discriminate on data.kind." }, { name: "commands", type: "ComposerCommandsMap", default: "{}", description: "Prefix → command config (kind, trigger, items, suggestion, placeholder). suggestion: false disables ghost-text completion; placeholder sets the per-prefix empty-query hint (never shown alongside a suggestion — the suggestion wins)." }, - { name: "questions", type: "AskUserQuestion[]", description: "When set, arms the ask-user flow (blurs the editor, flips askUser.active)." }, + { name: "requests", type: "ComposerRequest[]", description: "When set, arms the request flow (blurs the editor, flips requests.active)." }, { name: "isSubmitting", type: "boolean", default: "false", description: "Flips Submit to a stop affordance and gates re-submits." }, { name: "value", type: "ComposerSnapshot", description: "Controlled editor content." }, { name: "defaultValue", type: "ComposerSnapshot", description: "Uncontrolled initial editor content." }, @@ -272,7 +281,7 @@ export const Chat = () => { ```ts type ComposerSubmitData = | { kind: "message"; text: string; files: AttachmentItem[] } - | { kind: "answers"; answers: ComposerAnswerEntry[] }; + | { kind: "requests"; requests: ComposerRequestEntry[] }; ``` `files` are the generic attachment descriptors — your `onSubmit` adapts them to @@ -401,7 +410,7 @@ part-specific props. A surface region above the field. `children` is either plain nodes or a callback `(composer) => ReactNode` receiving the composer state, so you pick what to show -by priority (`commands.active ? : askUser.active ? : +by priority (`commands.active ? : requests.active ? : null`). By default (`anchor`) it renders as a **collision-aware, portaled overlay** anchored to the `Container` — flipping / shifting / sizing to stay on screen (via `@floating-ui/dom`); anchor a ref/element elsewhere, or pass `anchor={false}` for an @@ -553,18 +562,18 @@ still flows top-to-bottom — and calls the callback once per group with ``` -### Ask-user +### Ask -The composer owns the ask-user *state* — `questions`, the current step, the -answers — but renders none of the question UI. Compose that from the `AskUser` -namespace in `@intentface/ui/ask-user`, reading the step through -`useComposer((c) => c.askUser)` and gating the enclosing `Panel` or `Popover` on -`askUser.active`. +The composer owns the request *state* — `items`, the current step, the +drafts — but renders none of the prompt UI. Compose that from the `Ask` +namespace in `@intentface/ui/ask`, reading the step through +`useComposer((c) => c.requests)` and gating the enclosing `Panel` or `Popover` +on `requests.active`. -`AskUser.Dismiss` is a plain button you wire to `askUser.dismissStep`; -`AskUser.Continue` is `type="submit"`, so the enclosing `Composer.Root` form +`Ask.Dismiss` is a plain button you wire to `requests.dismissStep`; +`Ask.Continue` is `type="submit"`, so the enclosing `Composer.Root` form drives it. Neither ships copy — supply the labels as children, and read -`askUser.isLastStep` to switch the continue wording. +`requests.isLastStep` to switch the continue wording. ### Hooks diff --git a/content/docs/primitives/composer/demos/approval-flow.tsx b/content/docs/primitives/composer/demos/approval-flow.tsx new file mode 100644 index 00000000..caa6d342 --- /dev/null +++ b/content/docs/primitives/composer/demos/approval-flow.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { Ask } from "@intentface/ui/ask"; +import { + Composer, + type ComposerRequest, + type ComposerRequestEntry, + type ComposerRequestOption, + type ComposerSubmitData, + useComposer, +} from "@intentface/ui/composer"; +import { type ComponentProps, useState } from "react"; + +// A tool approval is just a request: the id is the tool call id, the option +// values are your decision vocabulary, and the entry routes back to the tool +// in onSubmit. The package never learns the word "approval" — and a single +// request simply doesn't mount the step-navigation parts. +const COMMAND = "pnpm test"; +const TOOL_CALL_ID = "call_run_tests"; + +// Richer consumer options satisfy the contract structurally — `command` is +// this demo's own field, rendered as inline code below. +const OPTIONS = [ + { value: "yes", label: "Yes" }, + { + value: "always", + label: "Yes, and don't ask again for commands that start with", + command: COMMAND, + }, + { value: "no", label: "No" }, +] satisfies readonly (ComposerRequestOption & { command?: string })[]; + +const REQUESTS: ComposerRequest[] = [ + { + id: TOOL_CALL_ID, + label: `Do you want to allow me to run ${COMMAND} for this workspace?`, + options: OPTIONS, + }, +]; + +const DECISION_COPY: Record = { + yes: "approved once", + always: `approved for commands starting with "${COMMAND}"`, + no: "denied", +}; + +export const ApprovalFlow = () => { + const [entry, setEntry] = useState(null); + + const handleSubmit = (data: ComposerSubmitData) => { + if (data.kind !== "requests") return; + // Route by the id we minted — this is where a real app resolves the call + // (e.g. addToolOutput / executing or rejecting the tool). + const resolved = data.requests.find((request) => request.id === TOOL_CALL_ID); + if (resolved) setEntry(resolved); + }; + + const decision = entry?.selected[0]; + const resolvedCopy = decision + ? (DECISION_COPY[decision] ?? decision) + : entry?.text + ? `answered: "${entry.text}"` + : "skipped"; + + return ( + // Reserve height and bottom-anchor so the panel opening never shifts the page. +
+ + + {!entry && } + + + + + + + {entry ? ( + + + + ) : ( + + )} + + + + {entry && ( + + )} +
+ ); +}; + +// Single request: no Navigation, no StepLabel — the label, the command being +// approved (free-form children inside Ask.Root), and numbered options. +const Prompt = () => { + const requests = useComposer((composer) => composer.requests); + const request = requests.items?.[requests.step]; + + if (!request) return null; + + const draft = requests.drafts.get(requests.step); + + return ( + + + + Do you want to allow me to run{" "} + + {COMMAND} + {" "} + for this workspace? + + +
+ {COMMAND} +
+ + {OPTIONS.map((option, index) => { + const selected = Boolean(draft?.selected.has(option.label)); + return ( + requests.toggleOption(option.label)} + className="group flex cursor-pointer items-start gap-2 rounded-[10px] p-2 outline-none transition-colors data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]" + > + + + + {option.label} + {"command" in option && ( + <> + {" "} + + {option.command} + + + )} + + + {/* Enter selects the highlighted row — surface that on it. */} + + + ); + })} + +
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); + +const Controls = () => { + const requests = useComposer((composer) => composer.requests); + + return ( + <> + + Skip + + + Submit + + + ); +}; diff --git a/content/docs/primitives/composer/demos/ask-user-flow.tsx b/content/docs/primitives/composer/demos/ask-flow.tsx similarity index 72% rename from content/docs/primitives/composer/demos/ask-user-flow.tsx rename to content/docs/primitives/composer/demos/ask-flow.tsx index 7f7641a9..c9bc1c9d 100644 --- a/content/docs/primitives/composer/demos/ask-user-flow.tsx +++ b/content/docs/primitives/composer/demos/ask-flow.tsx @@ -1,58 +1,62 @@ "use client"; -import { type AskUserQuestion, Composer, type ComposerSubmitData } from "@intentface/ui/composer"; +import { Composer, type ComposerRequest, type ComposerSubmitData } from "@intentface/ui/composer"; import { ArrowUp } from "@keyline-icons/react"; import { useState } from "react"; -import { Controls, Prompt } from "./ask-user"; +import { Controls, Prompt } from "./ask"; -// Setting `questions` arms the flow and flips askUser.active. Answering or -// skipping the last one fires onSubmit with { kind: "answers" }. -const QUESTIONS: AskUserQuestion[] = [ +// Setting `requests` arms the flow and flips requests.active. Answering or +// skipping the last one fires onSubmit with { kind: "requests" } — entries +// keyed by the ids minted here, carrying option values (label fallback). +const REQUESTS: ComposerRequest[] = [ { - question: "Which framework are you deploying to?", + id: "framework", + label: "Which framework are you deploying to?", options: [ - { label: "Next.js", description: "App Router on Vercel." }, - { label: "Vite", description: "SPA on any static host." }, - { label: "Remix", description: "Full-stack on a Node server." }, + { value: "next", label: "Next.js", description: "App Router on Vercel." }, + { value: "vite", label: "Vite", description: "SPA on any static host." }, + { value: "remix", label: "Remix", description: "Full-stack on a Node server." }, ], }, { - question: "Which features do you need?", + id: "features", + label: "Which features do you need?", multiSelect: true, options: [ - { label: "Auth", description: "Sessions and sign-in." }, - { label: "Database", description: "Persistent storage." }, - { label: "File uploads", description: "Attachments and media." }, + { value: "auth", label: "Auth", description: "Sessions and sign-in." }, + { value: "database", label: "Database", description: "Persistent storage." }, + { value: "uploads", label: "File uploads", description: "Attachments and media." }, ], }, { - question: "What matters most for this project?", + id: "priority", + label: "What matters most for this project?", options: [ - { label: "Speed", description: "Ship as fast as possible." }, - { label: "Scale", description: "Handle heavy traffic." }, - { label: "Cost", description: "Keep the bill low." }, + { value: "speed", label: "Speed", description: "Ship as fast as possible." }, + { value: "scale", label: "Scale", description: "Handle heavy traffic." }, + { value: "cost", label: "Cost", description: "Keep the bill low." }, ], }, ]; -export const AskUserFlow = () => { - const [questions, setQuestions] = useState(QUESTIONS); +export const AskFlow = () => { + const [requests, setRequests] = useState(REQUESTS); const [done, setDone] = useState(false); const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind === "answers") setDone(true); + if (data.kind === "requests") setDone(true); }; const reset = () => { setDone(false); - setQuestions([...QUESTIONS]); + setRequests([...REQUESTS]); }; return ( // Reserve height and bottom-anchor so the panel opening never shifts the page.
diff --git a/content/docs/primitives/composer/demos/ask-user.tsx b/content/docs/primitives/composer/demos/ask.tsx similarity index 50% rename from content/docs/primitives/composer/demos/ask-user.tsx rename to content/docs/primitives/composer/demos/ask.tsx index cdc01108..fa745956 100644 --- a/content/docs/primitives/composer/demos/ask-user.tsx +++ b/content/docs/primitives/composer/demos/ask.tsx @@ -1,64 +1,64 @@ "use client"; -import { AskUser } from "@intentface/ui/ask-user"; +import { Ask } from "@intentface/ui/ask"; import { useComposer } from "@intentface/ui/composer"; -// The parts are structural; the current question and selections come from the -// composer's askUser slice. +// The parts are structural; the current request and selections come from the +// composer's requests slice. export const Prompt = () => { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; + const requests = useComposer((composer) => composer.requests); + const request = requests.items?.[requests.step]; - if (!question) return null; + if (!request) return null; - const entry = askUser.answers.get(askUser.step); - const total = askUser.questions?.length ?? 0; + const draft = requests.drafts.get(requests.step); + const total = requests.items?.length ?? 0; return ( - - - - {question.question} - - {!askUser.isSingle && total > 1 && ( - - + + + {request.label} + + {!requests.isSingle && total > 1 && ( + + ‹ - - + + {({ current, total: count }) => `${current} of ${count}`} - - + › - - + + )} - - {question.options && ( - + {request.options && ( + - {question.options.map((option) => { - const selected = Boolean(entry?.selected.has(option.label)); + {request.options.map((option) => { + const selected = Boolean(draft?.selected.has(option.label)); return ( - askUser.toggleOption(option.label)} + onSelect={() => requests.toggleOption(option.label)} className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 outline-none transition-colors data-highlighted:bg-zinc-950/5 dark:data-highlighted:bg-white/8" > {/* Decorative: the Option itself carries the radio/checkbox role. */} @@ -72,41 +72,41 @@ export const Prompt = () => { > {selected ? "✓" : ""} - - + + {option.label} - + {option.description && ( - + {option.description} - + )} - - + + ); })} - + )} - + ); }; // Dismiss is a plain button you wire up; Continue is type=submit, so the // enclosing Composer.Root form drives it. export const Controls = () => { - const askUser = useComposer((composer) => composer.askUser); + const requests = useComposer((composer) => composer.requests); return ( <> - Skip - - - {askUser.isLastStep ? "Done" : "Continue"} - + + + {requests.isLastStep ? "Done" : "Continue"} + ); }; diff --git a/content/docs/quick-start.mdx b/content/docs/quick-start.mdx index 695aa7dd..2d954099 100644 --- a/content/docs/quick-start.mdx +++ b/content/docs/quick-start.mdx @@ -98,5 +98,5 @@ URL. Feed it to an assistant to let it navigate the docs without crawling HTML. ## Next steps - [Styling](/handbook/styling) — the state model, data attributes, and render props -- [Composer](/primitives/composer) — commands, chips, attachments, and the ask-user flow +- [Composer](/primitives/composer) — commands, chips, attachments, and the request flow - [Thread](/primitives/thread) — the scroll container and auto-follow behavior diff --git a/lib/ai/types.ts b/lib/ai/types.ts index 6b7f7f11..40e9e61b 100644 --- a/lib/ai/types.ts +++ b/lib/ai/types.ts @@ -1,4 +1,3 @@ -import type { AskUserQuestion as ComposerAskUserQuestion } from "@intentface/ui/composer"; import type { ChatMessage, ChatStatus, FilePart } from "@intentface/ui/types"; import type * as sdk from "ai"; import type { askUser } from "@/tools/ask-user"; @@ -65,8 +64,7 @@ export type FilePartContractCheck = Assert; -// The askUser tool's question must keep satisfying the composer's generic -// question contract (its `questions` prop). -export type AskUserQuestionContractCheck = Assert< - AskUserQuestion extends ComposerAskUserQuestion ? true : false ->; +// No assignability lock for the composer's `requests` prop: ComposerRequest +// requires a consumer-minted `id`, so tool questions are mapped explicitly at +// the boundary (useAskUserPanelState in components/chat.tsx) rather than +// passed through structurally. diff --git a/next.config.ts b/next.config.ts index a4309d71..d05941be 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,7 +13,7 @@ const packageSourceAliases = { "@intentface/ui/reasoning": "./packages/ui/src/reasoning/index.ts", "@intentface/ui/steps": "./packages/ui/src/steps/index.ts", "@intentface/ui/attachments": "./packages/ui/src/attachments/index.ts", - "@intentface/ui/ask-user": "./packages/ui/src/ask-user/index.ts", + "@intentface/ui/ask": "./packages/ui/src/ask/index.ts", "@intentface/ui/message": "./packages/ui/src/message/index.ts", "@intentface/ui/message-utils": "./packages/ui/src/message-utils.ts", "@intentface/ui/chip-markdown": "./packages/ui/src/chip-markdown.ts", diff --git a/packages/ui/README.md b/packages/ui/README.md index b69d5ffc..e7866a51 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -70,14 +70,14 @@ tabs are app-shell parts rather than chat parts, and are useful on their own: | Import | What it is | | --- | --- | -| `@intentface/ui/composer` | Rich-text input over a purpose-built contenteditable engine: `/` and `@` command palette, inline chips, attachments, ask-user flow. One store per ``, or bring your own via `Composer.createStore()`. | +| `@intentface/ui/composer` | Rich-text input over a purpose-built contenteditable engine: `/` and `@` command palette, inline chips, attachments, request flow (questions and tool approvals). One store per ``, or bring your own via `Composer.createStore()`. | | `@intentface/ui/thread` | Scroll container with at-bottom detection, auto-follow, and docked-composer measurement. | | `@intentface/ui/message` | Message parts, turns, chip-segmented text, selection hooks. | | `@intentface/ui/steps`, `/reasoning` | Tool-call timelines and reasoning disclosure. | | `@intentface/ui/shell` | A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot. | | `@intentface/ui/nav` | A nav tree with roving focus, typeahead, collapsible groups, and a guide ladder down the left edge. | | `@intentface/ui/tabs` | An open-ended, closable tab collection whose panel renders in the layout or anchored to its own tab. | -| `@intentface/ui/chip`, `/attachments`, `/ask-user` | The remaining building blocks. | +| `@intentface/ui/chip`, `/attachments`, `/ask` | The remaining building blocks. | | `@intentface/ui/generative` | Experimental. Typed helpers that check a model's UI tool call against a catalog, read it while it streams, resolve its data bindings, and write it as Markdown. | | `@intentface/ui/types` | The structural message contract + part type guards. | | `@intentface/ui/message-utils` | Part segmentation, turn grouping, reasoning/source derivation. | diff --git a/packages/ui/package.json b/packages/ui/package.json index 67703249..bc12982e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -63,9 +63,9 @@ "types": "./dist/attachments/index.d.ts", "default": "./dist/attachments/index.js" }, - "./ask-user": { - "types": "./dist/ask-user/index.d.ts", - "default": "./dist/ask-user/index.js" + "./ask": { + "types": "./dist/ask/index.d.ts", + "default": "./dist/ask/index.js" }, "./message": { "types": "./dist/message/index.d.ts", diff --git a/packages/ui/src/ask-user/index.parts.ts b/packages/ui/src/ask-user/index.parts.ts deleted file mode 100644 index e9f52aba..00000000 --- a/packages/ui/src/ask-user/index.parts.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Part names for the `AskUser` namespace. Deliberately carries no -// "use client" directive: this module and index.ts must stay server-resolvable -// so a React Server Component can reach AskUser.Root and its siblings -// through them. The directive lives one level down, on ask-user.tsx. -export { - AskUserContinue as Continue, - AskUserDismiss as Dismiss, - AskUserHeader as Header, - AskUserHints as Hints, - AskUserLabel as Label, - AskUserNavigation as Navigation, - AskUserNext as Next, - AskUserOption as Option, - AskUserOptionContent as OptionContent, - AskUserOptionDescription as OptionDescription, - AskUserOptionLabel as OptionLabel, - AskUserOptions as Options, - AskUserPrevious as Previous, - AskUserRoot as Root, - AskUserStepLabel as StepLabel, -} from "./ask-user"; diff --git a/packages/ui/src/ask-user/index.ts b/packages/ui/src/ask-user/index.ts deleted file mode 100644 index c874f7d4..00000000 --- a/packages/ui/src/ask-user/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Public surface of @intentface/ui/ask-user. No directive — see index.parts.ts. - -export type { - AskUserContinueProps, - AskUserDismissProps, - AskUserHeaderProps, - AskUserHintsProps, - AskUserLabelProps, - AskUserNavigationProps, - AskUserNextProps, - AskUserOptionContentProps, - AskUserOptionDescriptionProps, - AskUserOptionLabelProps, - AskUserOptionProps, - AskUserOptionState, - AskUserOptionsHandle, - AskUserOptionsProps, - AskUserPreviousProps, - AskUserRootProps, - AskUserStepLabelProps, - AskUserStepLabelState, -} from "./ask-user"; -export { useAskUserOption, useAskUserOptions } from "./ask-user"; -export * as AskUser from "./index.parts"; diff --git a/packages/ui/src/ask-user/ask-user.tsx b/packages/ui/src/ask/ask.tsx similarity index 72% rename from packages/ui/src/ask-user/ask-user.tsx rename to packages/ui/src/ask/ask.tsx index 0645a92d..2e907df9 100644 --- a/packages/ui/src/ask-user/ask-user.tsx +++ b/packages/ui/src/ask/ask.tsx @@ -1,10 +1,10 @@ "use client"; -// Headless ask-user questionnaire parts. Owns the option registration system +// Headless ask parts — the request flow widget. Owns the option registration system // (cmdk pattern), highlight state, and the imperative keyboard-navigation // handle. Input widgets (checkbox/radio), icons, and the answered summary -// belong to the styled layer, which reads state via useAskUserOptions() / -// useAskUserOption(). Every part supports the Base UI render prop. +// belong to the styled layer, which reads state via useAskOptions() / +// useAskOption(). Every part supports the Base UI render prop. import { createContext, @@ -25,109 +25,99 @@ import type { PrimitiveProps } from "../internal/primitive-props"; import { useRenderElement } from "../internal/render/useRenderElement"; import { scrollIntoViewWithin } from "../internal/scroll-into-view-within"; -// Shared ids so Options can label itself from the question text and describe +// Shared ids so Options can label itself from the request label and describe // itself from the step indicator without any consumer wiring: Root mints both, // Label/StepLabel stamp them, Options references them. -type AskUserIdsContextValue = { labelId: string; stepLabelId: string }; +type AskIdsContextValue = { labelId: string; stepLabelId: string }; -const AskUserIdsContext = createContext({ labelId: "", stepLabelId: "" }); +const AskIdsContext = createContext({ labelId: "", stepLabelId: "" }); -/** AskUser root container. Stateless — consumers manage all state externally. */ -export type AskUserRootProps = PrimitiveProps<"div">; +/** Ask root container. Stateless — consumers manage all state externally. */ +export type AskRootProps = PrimitiveProps<"div">; -export const AskUserRoot = ({ className, render, style, ...elementProps }: AskUserRootProps) => { +export const AskRoot = ({ className, render, style, ...elementProps }: AskRootProps) => { const labelId = useId(); const stepLabelId = useId(); const element = useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user": "" }, elementProps] }, + { props: [{ "data-ask": "" }, elementProps] }, ); const ids = useMemo(() => ({ labelId, stepLabelId }), [labelId, stepLabelId]); - return {element}; + return {element}; }; -/** Question heading text. Carries the id `Options` uses as its accessible name. */ -export type AskUserLabelProps = PrimitiveProps<"p">; +/** Request label text. Carries the id `Options` uses as its accessible name. */ +export type AskLabelProps = PrimitiveProps<"p">; -export const AskUserLabel = ({ className, render, style, ...elementProps }: AskUserLabelProps) => { - const { labelId } = use(AskUserIdsContext); +export const AskLabel = ({ className, render, style, ...elementProps }: AskLabelProps) => { + const { labelId } = use(AskIdsContext); return useRenderElement( "p", { className, render, style }, - { props: [{ id: labelId || undefined, "data-ask-user-label": "" }, elementProps] }, + { props: [{ id: labelId || undefined, "data-ask-label": "" }, elementProps] }, ); }; /** Row container for `Label` and optional `Navigation`. */ -export type AskUserHeaderProps = PrimitiveProps<"div">; +export type AskHeaderProps = PrimitiveProps<"div">; -export const AskUserHeader = ({ className, render, style, ...elementProps }: AskUserHeaderProps) => +export const AskHeader = ({ className, render, style, ...elementProps }: AskHeaderProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user-header": "" }, elementProps] }, + { props: [{ "data-ask-header": "" }, elementProps] }, ); /** Row container for `Previous`, `StepLabel`, and `Next`. */ -export type AskUserNavigationProps = PrimitiveProps<"div">; +export type AskNavigationProps = PrimitiveProps<"div">; -export const AskUserNavigation = ({ - className, - render, - style, - ...elementProps -}: AskUserNavigationProps) => +export const AskNavigation = ({ className, render, style, ...elementProps }: AskNavigationProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user-navigation": "" }, elementProps] }, + { props: [{ "data-ask-navigation": "" }, elementProps] }, ); /** Navigate to the previous step. Default accessible name; override via aria-label. */ -export type AskUserPreviousProps = PrimitiveProps<"button">; +export type AskPreviousProps = PrimitiveProps<"button">; -export const AskUserPrevious = ({ - className, - render, - style, - ...elementProps -}: AskUserPreviousProps) => +export const AskPrevious = ({ className, render, style, ...elementProps }: AskPreviousProps) => useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Previous question", "data-ask-user-previous": "" }, elementProps] }, + { props: [{ "aria-label": "Previous question", "data-ask-previous": "" }, elementProps] }, ); /** Navigate to the next step. Default accessible name; override via aria-label. */ -export type AskUserNextProps = PrimitiveProps<"button">; +export type AskNextProps = PrimitiveProps<"button">; -export const AskUserNext = ({ className, render, style, ...elementProps }: AskUserNextProps) => +export const AskNext = ({ className, render, style, ...elementProps }: AskNextProps) => useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Next question", "data-ask-user-next": "" }, elementProps] }, + { props: [{ "aria-label": "Next question", "data-ask-next": "" }, elementProps] }, ); /** Step indicator. Reads the current step + total from the composer store; the * consumer supplies the text via `children` — either a node, or a callback * receiving `{ current, total }` (1-based current). No default format. */ -export type AskUserStepLabelState = { current: number; total: number }; +export type AskStepLabelState = { current: number; total: number }; -export type AskUserStepLabelProps = Omit, "children"> & { - children?: ReactNode | ((state: AskUserStepLabelState) => ReactNode); +export type AskStepLabelProps = Omit, "children"> & { + children?: ReactNode | ((state: AskStepLabelState) => ReactNode); }; -export const AskUserStepLabel = ({ +export const AskStepLabel = ({ children, className, render, style, ...elementProps -}: AskUserStepLabelProps) => { - const current = useComposer((composer) => composer.askUser.step) + 1; - const total = useComposer((composer) => composer.askUser.questions?.length ?? 0); - const { stepLabelId } = use(AskUserIdsContext); +}: AskStepLabelProps) => { + const current = useComposer((composer) => composer.requests.step) + 1; + const total = useComposer((composer) => composer.requests.items?.length ?? 0); + const { stepLabelId } = use(AskIdsContext); const content = typeof children === "function" ? children({ current, total }) : children; return useRenderElement( @@ -135,7 +125,7 @@ export const AskUserStepLabel = ({ { className, render, style }, { props: [ - { id: stepLabelId || undefined, "data-ask-user-step-label": "", children: content }, + { id: stepLabelId || undefined, "data-ask-step-label": "", children: content }, elementProps, ], }, @@ -161,10 +151,10 @@ const OptionsContext = createContext({ onItemHover: () => {}, }); -export const useAskUserOptions = () => use(OptionsContext); +export const useAskOptions = () => use(OptionsContext); -/** Imperative handle exposed by `AskUser.Options` for keyboard navigation. */ -export type AskUserOptionsHandle = { +/** Imperative handle exposed by `Ask.Options` for keyboard navigation. */ +export type AskOptionsHandle = { /** Move highlight by direction, focusing the newly highlighted option (roving tabindex). Returns the new highlighted value (null = past the list boundary). */ navigate: (direction: number) => string | null; select: () => { value: string } | null; @@ -172,21 +162,21 @@ export type AskUserOptionsHandle = { resetHighlight: () => void; /** Move DOM focus onto the highlighted option (or the first option when none is highlighted). */ focusHighlighted: () => void; - /** The options container element — the composer attaches its question-mode keydown handling here. */ + /** The options container element — the composer attaches its request-mode keydown handling here. */ getElement: () => HTMLElement | null; highlightedValue: string | null; }; -export type AskUserOptionsProps = Omit, "value" | "ref"> & { +export type AskOptionsProps = Omit, "value" | "ref"> & { /** When true, options render as multi-select (checkbox semantics in the styled layer). */ multiSelect?: boolean; /** Shared `name` attribute for all option inputs in this group. */ groupName?: string; /** Imperative ref for keyboard navigation (navigate, select, clearHighlight, resetHighlight). */ - ref?: RefObject; + ref?: RefObject; }; -export const AskUserOptions = ({ +export const AskOptions = ({ multiSelect = false, groupName = "", ref, @@ -195,11 +185,11 @@ export const AskUserOptions = ({ style, children, ...elementProps -}: AskUserOptionsProps) => { +}: AskOptionsProps) => { const registeredItems = useRef([]); const itemElements = useRef HTMLElement | null>>(new Map()); const containerRef = useRef(null); - const { labelId, stepLabelId } = use(AskUserIdsContext); + const { labelId, stepLabelId } = use(AskIdsContext); const [highlightedValue, setHighlightedValue] = useState(null); // Track highlight in a ref so imperative methods see latest value without re-binding. @@ -247,7 +237,7 @@ export const AskUserOptions = ({ const element = itemElements.current.get(itemValue)?.(); if (!element) return; element.focus({ preventScroll: true }); - const boundary = element.closest("[data-ask-user]") ?? element; + const boundary = element.closest("[data-ask]") ?? element; scrollIntoViewWithin(element.getBoundingClientRect(), element, boundary); }, []); @@ -315,7 +305,7 @@ export const AskUserOptions = ({ role: multiSelect ? "group" : "radiogroup", "aria-labelledby": labelId || undefined, "aria-describedby": stepLabelId || undefined, - "data-ask-user-options": "", + "data-ask-options": "", children, }, elementProps, @@ -353,22 +343,22 @@ const OptionContext = createContext({ onSelect: () => {}, }); -export const useAskUserOption = () => use(OptionContext); +export const useAskOption = () => use(OptionContext); -export type AskUserOptionState = { +export type AskOptionState = { /** Present as data-highlighted while keyboard/hover highlighted. */ highlighted: boolean; /** Present as data-selected while the option is selected. */ selected: boolean; }; -export type AskUserOptionProps = PrimitiveProps<"label", AskUserOptionState> & { +export type AskOptionProps = PrimitiveProps<"label", AskOptionState> & { value?: string; selected?: boolean; onSelect?: () => void; }; -export const AskUserOption = ({ +export const AskOption = ({ value = "", selected = false, onSelect, @@ -377,7 +367,7 @@ export const AskUserOption = ({ style, children, ...elementProps -}: AskUserOptionProps) => { +}: AskOptionProps) => { const id = useId(); const { multiSelect, highlightedValue, register, onItemHover } = use(OptionsContext); const optionRef = useRef(null); @@ -401,12 +391,12 @@ export const AskUserOption = ({ // The option IS the selectable control: role + checked state live // here, and the roving tabindex makes the highlighted option the // group's single tab stop. Never a native input — the composer's - // question-mode key scoping treats inputs as foreign editables. + // request-mode key scoping treats inputs as foreign editables. id, role: multiSelect ? "checkbox" : "radio", "aria-checked": selected, tabIndex: isHighlighted ? 0 : -1, - "data-ask-user-option": "", + "data-ask-option": "", onMouseMove: () => onItemHover(value), // Keep highlight and DOM focus unified when focus arrives by other // means (Tab into the group, SR virtual-cursor activation). @@ -449,86 +439,76 @@ const useItemRegistration = ( }; /** Flex column wrapper for `OptionLabel` and `OptionDescription`. */ -export type AskUserOptionContentProps = PrimitiveProps<"span">; +export type AskOptionContentProps = PrimitiveProps<"span">; -export const AskUserOptionContent = ({ +export const AskOptionContent = ({ className, render, style, ...elementProps -}: AskUserOptionContentProps) => +}: AskOptionContentProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-user-option-content": "" }, elementProps] }, + { props: [{ "data-ask-option-content": "" }, elementProps] }, ); /** Option title text. */ -export type AskUserOptionLabelProps = PrimitiveProps<"span">; +export type AskOptionLabelProps = PrimitiveProps<"span">; -export const AskUserOptionLabel = ({ +export const AskOptionLabel = ({ className, render, style, ...elementProps -}: AskUserOptionLabelProps) => +}: AskOptionLabelProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-user-option-label": "" }, elementProps] }, + { props: [{ "data-ask-option-label": "" }, elementProps] }, ); /** Option subtitle/description text. */ -export type AskUserOptionDescriptionProps = PrimitiveProps<"span">; +export type AskOptionDescriptionProps = PrimitiveProps<"span">; -export const AskUserOptionDescription = ({ +export const AskOptionDescription = ({ className, render, style, ...elementProps -}: AskUserOptionDescriptionProps) => +}: AskOptionDescriptionProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-user-option-description": "" }, elementProps] }, + { props: [{ "data-ask-option-description": "" }, elementProps] }, ); -/** Keyboard shortcut hints displayed below the ask-user options. */ -export type AskUserHintsProps = PrimitiveProps<"div">; +/** Keyboard shortcut hints displayed below the ask options. */ +export type AskHintsProps = PrimitiveProps<"div">; -export const AskUserHints = ({ className, render, style, ...elementProps }: AskUserHintsProps) => +export const AskHints = ({ className, render, style, ...elementProps }: AskHintsProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user-hints": "" }, elementProps] }, + { props: [{ "data-ask-hints": "" }, elementProps] }, ); /** Dismiss/skip button. Stateless — wire `onClick` to your dismiss handler. */ -export type AskUserDismissProps = PrimitiveProps<"button">; +export type AskDismissProps = PrimitiveProps<"button">; -export const AskUserDismiss = ({ - className, - render, - style, - ...elementProps -}: AskUserDismissProps) => +export const AskDismiss = ({ className, render, style, ...elementProps }: AskDismissProps) => useRenderElement( "button", { className, render, style }, - { props: [{ type: "button" as const, "data-ask-user-dismiss": "" }, elementProps] }, + { props: [{ type: "button" as const, "data-ask-dismiss": "" }, elementProps] }, ); /** Continue/submit button — `type=submit` so the enclosing form drives it. */ -export type AskUserContinueProps = PrimitiveProps<"button">; +export type AskContinueProps = PrimitiveProps<"button">; -export const AskUserContinue = ({ - className, - render, - style, - ...elementProps -}: AskUserContinueProps) => +export const AskContinue = ({ className, render, style, ...elementProps }: AskContinueProps) => useRenderElement( "button", { className, render, style }, - { props: [{ type: "submit" as const, "data-ask-user-continue": "" }, elementProps] }, + { props: [{ type: "submit" as const, "data-ask-continue": "" }, elementProps] }, ); diff --git a/packages/ui/src/ask/index.parts.ts b/packages/ui/src/ask/index.parts.ts new file mode 100644 index 00000000..2195399a --- /dev/null +++ b/packages/ui/src/ask/index.parts.ts @@ -0,0 +1,21 @@ +// Part names for the `Ask` namespace. Deliberately carries no +// "use client" directive: this module and index.ts must stay server-resolvable +// so a React Server Component can reach Ask.Root and its siblings +// through them. The directive lives one level down, on ask.tsx. +export { + AskContinue as Continue, + AskDismiss as Dismiss, + AskHeader as Header, + AskHints as Hints, + AskLabel as Label, + AskNavigation as Navigation, + AskNext as Next, + AskOption as Option, + AskOptionContent as OptionContent, + AskOptionDescription as OptionDescription, + AskOptionLabel as OptionLabel, + AskOptions as Options, + AskPrevious as Previous, + AskRoot as Root, + AskStepLabel as StepLabel, +} from "./ask"; diff --git a/packages/ui/src/ask/index.ts b/packages/ui/src/ask/index.ts new file mode 100644 index 00000000..49563493 --- /dev/null +++ b/packages/ui/src/ask/index.ts @@ -0,0 +1,24 @@ +// Public surface of @intentface/ui/ask. No directive — see index.parts.ts. + +export type { + AskContinueProps, + AskDismissProps, + AskHeaderProps, + AskHintsProps, + AskLabelProps, + AskNavigationProps, + AskNextProps, + AskOptionContentProps, + AskOptionDescriptionProps, + AskOptionLabelProps, + AskOptionProps, + AskOptionState, + AskOptionsHandle, + AskOptionsProps, + AskPreviousProps, + AskRootProps, + AskStepLabelProps, + AskStepLabelState, +} from "./ask"; +export { useAskOption, useAskOptions } from "./ask"; +export * as Ask from "./index.parts"; diff --git a/packages/ui/src/composer/actions.tsx b/packages/ui/src/composer/actions.tsx index 952acba2..c0c81836 100644 --- a/packages/ui/src/composer/actions.tsx +++ b/packages/ui/src/composer/actions.tsx @@ -35,7 +35,7 @@ export const ComposerContextWindow = ({ // Yield while any native panel is active — the strip slides back out once they // close. (Union of the native slices; no central registry.) const anyPanelActive = useComposer( - (composer) => composer.commands.active || composer.askUser.active, + (composer) => composer.commands.active || composer.requests.active, ); const hasContent = Children.toArray(children).length > 0; const open = hasContent && !anyPanelActive; diff --git a/packages/ui/src/composer/index.ts b/packages/ui/src/composer/index.ts index fec66f18..60caeda2 100644 --- a/packages/ui/src/composer/index.ts +++ b/packages/ui/src/composer/index.ts @@ -12,12 +12,13 @@ export { useComposerController } from "./controller"; export { filterArrayItems, fuzzyScore, suggestionRemainder } from "./fuzzy"; export * as Composer from "./index.parts"; export type { ComposerSubmitOn, EditorKeyAction, EditorKeyContext } from "./keyboard"; -export { interpretAskUserKey, interpretEditorKey } from "./keyboard"; +export { interpretEditorKey, interpretRequestKey } from "./keyboard"; export type { ComposerPanelProps } from "./panel"; export type { ComposerPlaceholderProps } from "./placeholder"; export type { ComposerPopoverProps } from "./popover"; export type { ActiveTokenState, RegisteredPrefix } from "./prefix-detection"; export { CLOSED_COMMAND_STATE, detectActivePrefix } from "./prefix-detection"; +export type { RequestDraft } from "./request-machine"; export type { ComposerRootProps } from "./root"; export type { Segment, SegmentDoc, TextChange } from "./segments"; export { @@ -28,30 +29,30 @@ export { spliceSegments, } from "./segments"; export type { - ComposerAskUserState, ComposerAttachmentsState, ComposerCommandsState, ComposerEditorState, ComposerPanelSlice, + ComposerRequestsState, ComposerState, ComposerStore, } from "./store"; export { useComposer, useComposerStore } from "./store"; export type { ComposerTextareaProps, ComposerTextareaState } from "./textarea"; export type { - AskUserOption, - AskUserQuestion, AttachmentsApi, ChipData, CommandItemData, CommandItemKind, - ComposerAnswerEntry, - ComposerAnswersSubmit, ComposerCommandsConfig, ComposerCommandsItems, ComposerCommandsMap, ComposerEditorHandle, ComposerMessageSubmit, + ComposerRequest, + ComposerRequestEntry, + ComposerRequestOption, + ComposerRequestsSubmit, ComposerSnapshot, ComposerSubmitData, PrefixOnSelectContext, diff --git a/packages/ui/src/composer/keyboard.ts b/packages/ui/src/composer/keyboard.ts index 62e4e7ff..52d0746f 100644 --- a/packages/ui/src/composer/keyboard.ts +++ b/packages/ui/src/composer/keyboard.ts @@ -8,8 +8,8 @@ export type EditorKeyAction = | { type: "command-close" } | { type: "command-navigate"; direction: 1 | -1 } | { type: "command-caret"; direction: 1 | -1 } - | { type: "ask-user-arrow"; direction: 1 | -1 } - | { type: "ask-user-dismiss" } + | { type: "request-arrow"; direction: 1 | -1 } + | { type: "request-dismiss" } | { type: "remove-last-attachment" } | { type: "submit-form" } | { type: "soft-break" }; @@ -19,7 +19,7 @@ export type ComposerSubmitOn = "enter" | "shift-enter"; export type EditorKeyContext = { isCommandListOpen: boolean; - hasActiveAskUser: boolean; + hasActiveRequests: boolean; isEditorEmpty: boolean; hasAttachments: boolean; /** Defaults to "enter" (Enter sends, Shift+Enter breaks). */ @@ -31,7 +31,7 @@ export const interpretEditorKey = ( context: EditorKeyContext, ): EditorKeyAction | null => { const { key, shiftKey } = event; - const { isCommandListOpen, hasActiveAskUser } = context; + const { isCommandListOpen, hasActiveRequests } = context; switch (true) { case isCommandListOpen && key === "Tab": @@ -49,14 +49,14 @@ export const interpretEditorKey = ( case isCommandListOpen && key === "Enter" && !shiftKey: return { type: "command-select" }; - case hasActiveAskUser && key === "ArrowUp": - return { type: "ask-user-arrow", direction: -1 }; - case hasActiveAskUser && key === "ArrowDown": - return { type: "ask-user-arrow", direction: 1 }; - // Question-mode keys now attach to the options container (roving focus), + case hasActiveRequests && key === "ArrowUp": + return { type: "request-arrow", direction: -1 }; + case hasActiveRequests && key === "ArrowDown": + return { type: "request-arrow", direction: 1 }; + // Request-mode keys now attach to the options container (roving focus), // so an editor-focused Escape must dismiss from here. - case hasActiveAskUser && key === "Escape": - return { type: "ask-user-dismiss" }; + case hasActiveRequests && key === "Escape": + return { type: "request-dismiss" }; case key === "Backspace" && context.isEditorEmpty && context.hasAttachments: return { type: "remove-last-attachment" }; @@ -77,7 +77,7 @@ export const interpretEditorKey = ( } }; -export type AskUserKeyAction = +export type RequestKeyAction = | { type: "dismiss-step" } | { type: "navigate-options"; direction: 1 | -1 } | { type: "select-option" } @@ -85,7 +85,7 @@ export type AskUserKeyAction = | { type: "go-next" } | { type: "insert-character"; character: string }; -export type AskUserKeyEvent = { +export type RequestKeyEvent = { key: string; ctrlKey: boolean; metaKey: boolean; @@ -93,10 +93,10 @@ export type AskUserKeyEvent = { defaultPrevented: boolean; }; -export const interpretAskUserKey = ( - event: AskUserKeyEvent, +export const interpretRequestKey = ( + event: RequestKeyEvent, context: { hasHighlight: boolean }, -): AskUserKeyAction | null => { +): RequestKeyAction | null => { const { key } = event; switch (true) { diff --git a/packages/ui/src/composer/panel.tsx b/packages/ui/src/composer/panel.tsx index fa460a5f..1b910d3c 100644 --- a/packages/ui/src/composer/panel.tsx +++ b/packages/ui/src/composer/panel.tsx @@ -1,6 +1,6 @@ "use client"; -// Composer.Panel — a surface region for the command list / ask-user / steps. `children` +// Composer.Panel — a surface region for the command list / ask / steps. `children` // is either normal nodes or a callback receiving the whole composer state, so you can pick // what to show by priority: `{(c) => c.commands.active ? : …}`. // diff --git a/packages/ui/src/composer/ask-user-machine.ts b/packages/ui/src/composer/request-machine.ts similarity index 50% rename from packages/ui/src/composer/ask-user-machine.ts rename to packages/ui/src/composer/request-machine.ts index fdd8373b..d000b50d 100644 --- a/packages/ui/src/composer/ask-user-machine.ts +++ b/packages/ui/src/composer/request-machine.ts @@ -1,41 +1,41 @@ -// Ask-user — pure state machine for the questionnaire flow. State is a current -// step plus a map of per-step answers; every transition is an immutable map +// Requests — pure state machine for the request flow. State is a current step +// plus a map of per-step drafts; every transition is an immutable map // operation, so the store only owns the state cell and the effect execution. -import type { AskUserQuestion, ComposerAnswerEntry } from "./types"; +import type { ComposerRequest, ComposerRequestEntry } from "./types"; // --------------------------------------------------------------------------- // Types — the machine state plus the effect/action/transition vocabulary the // store speaks to it in. // --------------------------------------------------------------------------- -export type AnswerEntry = { +export type RequestDraft = { selected: Set; freeText: string; }; -export type AskUserState = { +export type RequestState = { step: number; - answers: Map; + drafts: Map; }; -export const INITIAL_ASK_USER_STATE: AskUserState = { +export const INITIAL_REQUEST_STATE: RequestState = { step: 0, - answers: new Map(), + drafts: new Map(), }; // Everything a transition may ask of the outside world. The store executes // these against the editor controller, the options handle, and the submit // callback — the transitions below only describe them. -export type AskUserEffect = +export type RequestEffect = | { type: "clear-input" } | { type: "set-input-text"; text: string } | { type: "focus-input" } | { type: "focus-options" } | { type: "reset-highlight" } - | { type: "submit-answers"; answers: ComposerAnswerEntry[] }; + | { type: "submit-requests"; requests: ComposerRequestEntry[] }; -export type AskUserAction = +export type RequestAction = | { type: "toggle-option"; label: string } | { type: "select-option"; label: string } | { type: "clear-selections" } @@ -44,36 +44,36 @@ export type AskUserAction = | { type: "step-back"; currentText: string } | { type: "step-forward"; currentText: string }; -export type AskUserTransition = { - next: AskUserState; - effects: AskUserEffect[]; +export type RequestTransition = { + next: RequestState; + effects: RequestEffect[]; }; // --------------------------------------------------------------------------- -// Answer helpers — immutable operations on the per-step answer map, plus the +// Draft helpers — immutable operations on the per-step draft map, plus the // projection/query the store reads. // --------------------------------------------------------------------------- -const emptyEntry = (): AnswerEntry => ({ +const emptyDraft = (): RequestDraft => ({ selected: new Set(), freeText: "", }); -const cloneAnswers = (source: Map) => new Map(source); +const cloneDrafts = (source: Map) => new Map(source); // Record `text` as the step's free-text answer. Single-select treats text and // a chosen option as mutually exclusive, so any selection is dropped; // multi-select keeps existing selections alongside the text. Empty text is a // no-op (returns an untouched clone). const writeFreeText = ( - answers: Map, + drafts: Map, step: number, text: string, multiSelect: boolean, ) => { - const next = cloneAnswers(answers); + const next = cloneDrafts(drafts); if (text.length === 0) return next; - const previous = next.get(step) ?? emptyEntry(); + const previous = next.get(step) ?? emptyDraft(); next.set(step, { selected: multiSelect ? previous.selected : new Set(), freeText: text, @@ -81,24 +81,24 @@ const writeFreeText = ( return next; }; -// Remove the step's entry entirely (skip / dismiss). -const skipStep = (answers: Map, step: number): Map => { - const next = cloneAnswers(answers); +// Remove the step's draft entirely (skip / dismiss). +const skipStep = (drafts: Map, step: number): Map => { + const next = cloneDrafts(drafts); next.delete(step); return next; }; -// Toggle an option on the step's entry. Multi-select toggles membership and +// Toggle an option on the step's draft. Multi-select toggles membership and // keeps free text; single-select replaces both (option and text are mutually // exclusive). const toggleAnswer = ( - answers: Map, + drafts: Map, step: number, label: string, multiSelect: boolean, ) => { - const next = cloneAnswers(answers); - const previous = next.get(step) ?? emptyEntry(); + const next = cloneDrafts(drafts); + const previous = next.get(step) ?? emptyDraft(); const selected = new Set(previous.selected); if (multiSelect) { if (selected.has(label)) selected.delete(label); @@ -111,50 +111,44 @@ const toggleAnswer = ( return next; }; -// Project the collected answers onto the public ComposerAnswerEntry union, one -// entry per question in order. The chosen branch encodes the invariant: -// single-select carries `option` *or* `text`; multi-select carries both; -// an unanswered question is bare `{ question }`. -export const compileAnswers = ( - state: AskUserState, - questions: AskUserQuestion[], -): ComposerAnswerEntry[] => - questions.map(({ question, multiSelect }, index) => { - const entry = state.answers.get(index); - if (!entry) return { question }; - - const selected = [...entry.selected]; - const text = entry.freeText.trim(); - - if (multiSelect) { - if (selected.length === 0 && text === "") return { question }; - return { question, options: selected, text }; - } - - const firstSelected = selected[0]; - if (firstSelected !== undefined) return { question, option: firstSelected }; - if (text !== "") return { question, text }; - return { question }; +// Project the drafts onto the public flat entry shape, one entry per request in +// order. Selected keys resolve to option values (label fallback); `text` is +// present only when the user typed something. +export const compileRequests = ( + state: RequestState, + requests: ComposerRequest[], +): ComposerRequestEntry[] => + requests.map((request, index) => { + const draft = state.drafts.get(index); + if (!draft) return { id: request.id, selected: [] }; + + const selected = [...draft.selected].map((key) => { + const option = request.options.find((o) => o.label === key || o.value === key); + return option ? (option.value ?? option.label) : key; + }); + const text = draft.freeText.trim(); + + return text === "" ? { id: request.id, selected } : { id: request.id, selected, text }; }); -export const isLastStep = (state: AskUserState, questions: AskUserQuestion[]) => - state.step >= questions.length - 1; +export const isLastStep = (state: RequestState, requests: ComposerRequest[]) => + state.step >= requests.length - 1; // --------------------------------------------------------------------------- // Transitions — the decide half of the flow: map an action onto the next state -// plus the effects to run. transitionAskUser is the single entry point. +// plus the effects to run. transitionRequests is the single entry point. // --------------------------------------------------------------------------- -// Commit `nextAnswers`, advance one step, and reset the input — then either -// arm the next question (blurred, highlight reset) or compile and submit on +// Commit `nextDrafts`, advance one step, and reset the input — then either +// arm the next request (blurred, highlight reset) or compile and submit on // the last step (input focused for the follow-up message). const advanceStep = ( - state: AskUserState, - questions: AskUserQuestion[], - nextAnswers: Map, -): AskUserTransition => { - const next: AskUserState = { step: state.step + 1, answers: nextAnswers }; - if (state.step < questions.length - 1) { + state: RequestState, + requests: ComposerRequest[], + nextDrafts: Map, +): RequestTransition => { + const next: RequestState = { step: state.step + 1, drafts: nextDrafts }; + if (state.step < requests.length - 1) { return { next, effects: [{ type: "clear-input" }, { type: "reset-highlight" }, { type: "focus-options" }], @@ -164,7 +158,7 @@ const advanceStep = ( next, effects: [ { type: "clear-input" }, - { type: "submit-answers", answers: compileAnswers(next, questions) }, + { type: "submit-requests", requests: compileRequests(next, requests) }, { type: "focus-input" }, ], }; @@ -173,74 +167,74 @@ const advanceStep = ( // Navigate to `targetStep`, preserving any in-progress free text on the step // being left and restoring the target step's saved text into the input. const transitionToStep = ( - state: AskUserState, + state: RequestState, targetStep: number, currentText: string, -): AskUserTransition => { +): RequestTransition => { if (targetStep === state.step) return { next: state, effects: [] }; const trimmed = currentText.trim(); - const answers = cloneAnswers(state.answers); + const drafts = cloneDrafts(state.drafts); if (trimmed.length > 0) { - const previous = answers.get(state.step) ?? emptyEntry(); - answers.set(state.step, { ...previous, freeText: trimmed }); + const previous = drafts.get(state.step) ?? emptyDraft(); + drafts.set(state.step, { ...previous, freeText: trimmed }); } - const next: AskUserState = { step: targetStep, answers }; + const next: RequestState = { step: targetStep, drafts }; return { next, effects: [ - { type: "set-input-text", text: answers.get(targetStep)?.freeText ?? "" }, + { type: "set-input-text", text: drafts.get(targetStep)?.freeText ?? "" }, { type: "reset-highlight" }, { type: "focus-options" }, ], }; }; -// The decide half of the ask-user flow, mirroring interpretAskUserKey: map an +// The decide half of the request flow, mirroring interpretRequestKey: map an // action onto the next state plus the effects to run. Composed actions // (select-option = toggle + advance) resolve atomically here, so no caller // ever chains transitions across a stale state snapshot. -export const transitionAskUser = ( - state: AskUserState, - questions: AskUserQuestion[], - action: AskUserAction, -): AskUserTransition => { - const multiSelect = !!questions[state.step]?.multiSelect; +export const transitionRequests = ( + state: RequestState, + requests: ComposerRequest[], + action: RequestAction, +): RequestTransition => { + const multiSelect = !!requests[state.step]?.multiSelect; switch (action.type) { case "toggle-option": return { next: { ...state, - answers: toggleAnswer(state.answers, state.step, action.label, multiSelect), + drafts: toggleAnswer(state.drafts, state.step, action.label, multiSelect), }, effects: multiSelect ? [] : [{ type: "clear-input" }], }; case "select-option": { - const toggled = toggleAnswer(state.answers, state.step, action.label, multiSelect); - if (multiSelect) return { next: { ...state, answers: toggled }, effects: [] }; - return advanceStep(state, questions, toggled); + const toggled = toggleAnswer(state.drafts, state.step, action.label, multiSelect); + if (multiSelect) return { next: { ...state, drafts: toggled }, effects: [] }; + return advanceStep(state, requests, toggled); } case "clear-selections": { - const previous = state.answers.get(state.step); + const previous = state.drafts.get(state.step); if (!previous || previous.selected.size === 0) return { next: state, effects: [] }; - const answers = cloneAnswers(state.answers); - answers.set(state.step, { selected: new Set(), freeText: previous.freeText }); - return { next: { ...state, answers }, effects: [] }; + const drafts = cloneDrafts(state.drafts); + drafts.set(state.step, { selected: new Set(), freeText: previous.freeText }); + return { next: { ...state, drafts }, effects: [] }; } case "continue-step": return advanceStep( state, - questions, - writeFreeText(state.answers, state.step, action.freeText.trim(), multiSelect), + requests, + writeFreeText(state.drafts, state.step, action.freeText.trim(), multiSelect), ); case "dismiss-step": - return advanceStep(state, questions, skipStep(state.answers, state.step)); + return advanceStep(state, requests, skipStep(state.drafts, state.step)); case "step-back": return transitionToStep(state, Math.max(0, state.step - 1), action.currentText); @@ -248,7 +242,7 @@ export const transitionAskUser = ( case "step-forward": return transitionToStep( state, - Math.min(questions.length - 1, state.step + 1), + Math.min(requests.length - 1, state.step + 1), action.currentText, ); } diff --git a/packages/ui/src/composer/root.tsx b/packages/ui/src/composer/root.tsx index 9c7e8690..8b16d266 100644 --- a/packages/ui/src/composer/root.tsx +++ b/packages/ui/src/composer/root.tsx @@ -19,8 +19,8 @@ import { } from "./internals"; import { type ComposerStore, ComposerStoreContext, createComposerStore } from "./store"; import type { - AskUserQuestion, ComposerCommandsMap, + ComposerRequest, ComposerSnapshot, ComposerSubmitData, } from "./types"; @@ -38,7 +38,7 @@ export type ComposerRootProps = Omit, onSubmit?: (data: ComposerSubmitData) => void | Promise; isSubmitting?: boolean; commands?: ComposerCommandsMap; - questions?: AskUserQuestion[]; + requests?: ComposerRequest[]; defaultValue?: ComposerSnapshot; value?: ComposerSnapshot; onValueChange?: (snapshot: ComposerSnapshot) => void; @@ -50,7 +50,7 @@ export const ComposerRoot = ({ onSubmit, isSubmitting = false, commands = EMPTY_COMMANDS, - questions, + requests, defaultValue, value, onValueChange, @@ -78,16 +78,17 @@ export const ComposerRoot = ({ const onSubmitRef = useAsRef(onSubmit); - // Register this mount on the store: answers submit through this mount's - // onSubmit. React also runs the cleanup without a real unmount (StrictMode, - // a hidden ), where the state lives on, so it only resets an owned - // store once React has removed the form — that happens on a real unmount only. + // Register this mount on the store: request entries submit through this + // mount's onSubmit. React also runs the cleanup without a real unmount + // (StrictMode, a hidden ), where the state lives on, so it only + // resets an owned store once React has removed the form — that happens on a + // real unmount only. useEffect(() => { const form = formRef.current; - store.submitAnswersRef.current = (answers) => - onSubmitRef.current?.({ kind: "answers", answers }); + store.submitRequestsRef.current = (requests) => + onSubmitRef.current?.({ kind: "requests", requests }); return () => { - store.submitAnswersRef.current = null; + store.submitRequestsRef.current = null; if (ownsStore && form && !form.isConnected) store.reset(); }; }, [store, ownsStore]); @@ -105,13 +106,13 @@ export const ComposerRoot = ({ store.setIsSubmitting(isSubmitting); }, [store, isSubmitting]); - // Sync the questions prop into the store and arm ask-user mode (editor blur - // + document-level keyboard handling) while questions are active. + // Sync the requests prop into the store and arm request mode (editor blur + // + document-level keyboard handling) while requests are active. useEffect(() => { - store.setQuestions(questions ?? null); - if (!questions?.length) return; - return store.activateAskUser(); - }, [store, questions]); + store.setRequests(requests ?? null); + if (!requests?.length) return; + return store.activateRequests(); + }, [store, requests]); const { getRegisteredPrefixes } = useCommandRegistry(commands); @@ -131,10 +132,10 @@ export const ComposerRoot = ({ const handleFormSubmit = async (event: React.SubmitEvent) => { event.preventDefault(); - const { askUser, attachments } = store.getSnapshot(); + const { requests, attachments } = store.getSnapshot(); - if (askUser.questions?.length) { - askUser.continueStep(store.controller.getText()); + if (requests.items?.length) { + requests.continueStep(store.controller.getText()); return; } @@ -165,8 +166,8 @@ export const ComposerRoot = ({ const handleKeyDown = (event: React.KeyboardEvent) => { const stopGenerating = store.stopGeneratingRef.current; if (event.key !== "Escape" || event.defaultPrevented || !stopGenerating) return; - // While a question is open, Escape dismisses its step instead. - if (store.getSnapshot().askUser.questions?.length) return; + // While a request is open, Escape dismisses its step instead. + if (store.getSnapshot().requests.items?.length) return; event.preventDefault(); stopGenerating(); }; diff --git a/packages/ui/src/composer/store.ts b/packages/ui/src/composer/store.ts index 431e813c..d48199e7 100644 --- a/packages/ui/src/composer/store.ts +++ b/packages/ui/src/composer/store.ts @@ -15,32 +15,32 @@ // snapshot. import { createContext, type RefObject, use, useSyncExternalStore } from "react"; -import type { AskUserOptionsHandle } from "../ask-user"; +import type { AskOptionsHandle } from "../ask"; import { type AttachmentErrorCode, type AttachmentItem, revokeAttachmentUrl, toAttachmentItem, } from "../attachments"; -import { - type AnswerEntry, - type AskUserAction, - type AskUserEffect, - INITIAL_ASK_USER_STATE, - isLastStep, - transitionAskUser, -} from "./ask-user-machine"; import { type AttachmentStoreAction, type AttachmentStoreConfig, attachmentReducer, INITIAL_ATTACHMENT_STATE, } from "./attachments-machine"; -import { interpretAskUserKey } from "./keyboard"; +import { interpretRequestKey } from "./keyboard"; +import { + INITIAL_REQUEST_STATE, + isLastStep, + type RequestAction, + type RequestDraft, + type RequestEffect, + transitionRequests, +} from "./request-machine"; import type { - AskUserQuestion, - ComposerAnswerEntry, ComposerEditorHandle, + ComposerRequest, + ComposerRequestEntry, RegisteredEditor, } from "./types"; @@ -90,10 +90,10 @@ export type ComposerAttachmentsState = { globalDropRef: RefObject; }; -export type ComposerAskUserState = ComposerPanelSlice & { - questions: AskUserQuestion[] | null; +export type ComposerRequestsState = ComposerPanelSlice & { + items: ComposerRequest[] | null; step: number; - answers: Map; + drafts: Map; toggleOption: (label: string) => void; continueStep: (freeText?: string) => void; dismissStep: () => void; @@ -102,11 +102,11 @@ export type ComposerAskUserState = ComposerPanelSlice & { clearSelections: () => void; goBack: () => void; goNext: () => void; - optionsRef: RefObject; + optionsRef: RefObject; }; -// The shared skeleton every native panel slice (commands, ask-user, and later -// tool-approval) is built on: `active` is the logical open flag a consumer gates +// The shared skeleton every native panel slice (commands, requests) is built +// on: `active` is the logical open flag a consumer gates // on (`{commands.active && <…/>}`); `present` is sticky-true — it stays set through // the close animation so the panel can keep the last content mounted while it // animates out, and is cleared only by finalizePanelClose() once the animation @@ -135,7 +135,7 @@ export type ComposerState = { isSubmitting: boolean; commands: ComposerCommandsState; attachments: ComposerAttachmentsState; - askUser: ComposerAskUserState; + requests: ComposerRequestsState; }; // --------------------------------------------------------------------------- @@ -159,10 +159,10 @@ export type ComposerStore = { listboxId: string; moveHighlight: (direction: number) => void; setHighlight: (index: number) => void; - setQuestions: (questions: AskUserQuestion[] | null) => void; + setRequests: (requests: ComposerRequest[] | null) => void; setDragging: (active: boolean) => void; resetAttachments: () => void; - activateAskUser: () => () => void; + activateRequests: () => () => void; // Clears `present` on native panel slices whose `active` is false — the Panel calls // this once its close animation finishes, so closing content stays mounted until then. finalizePanelClose: () => void; @@ -180,7 +180,7 @@ export type ComposerStore = { containerRef: RefObject; // Co-located refs the mounted Composer wires up at runtime. attachmentConfigRef: RefObject; - submitAnswersRef: RefObject<((answers: ComposerAnswerEntry[]) => void) | null>; + submitRequestsRef: RefObject<((requests: ComposerRequestEntry[]) => void) | null>; // The mounted Submit's stop while generating; Composer.Root calls it on Escape. stopGeneratingRef: RefObject<(() => void) | null>; // Invokes the active list's current selection. Registered by the mounted @@ -189,11 +189,11 @@ export type ComposerStore = { }; // --------------------------------------------------------------------------- -// Ask-user keydown — the document-level handler for question mode, kept out of -// the store factory so activateAskUser reads as just "blur, listen, unlisten". +// Request keydown — the document-level handler for request mode, kept out of +// the store factory so activateRequests reads as just "blur, listen, unlisten". // --------------------------------------------------------------------------- -// Focus moves the ask-user flow makes itself — back to the input, or in from +// Focus moves the request flow makes itself — back to the input, or in from // an option. The composer is on screen already, and the browser's // focus-scroll would also move the page around an embedded composer. const FOCUS_IN_PLACE: FocusOptions = { preventScroll: true }; @@ -210,22 +210,22 @@ const isEventForComposer = ( const target = event.target as HTMLElement | null; const editableHost = target?.closest('input, textarea, [contenteditable="true"]'); if (editableHost) return editableHost === editorDom; - const optionsHost = target?.closest("[data-ask-user-options]"); + const optionsHost = target?.closest("[data-ask-options]"); if (optionsHost) return optionsHost === optionsElement; return true; }; -type AskUserKeydownDeps = { +type RequestKeydownDeps = { controller: ComposerEditorState; - optionsRef: RefObject; + optionsRef: RefObject; editorRef: RefObject; - dispatch: (action: AskUserAction) => void; + dispatch: (action: RequestAction) => void; }; // Interpret a key (pure) and drive the machine + editor controller. Mirrors the -// action union from interpretAskUserKey one-to-one. -const createAskUserKeydownHandler = - ({ controller, optionsRef, editorRef, dispatch }: AskUserKeydownDeps) => +// action union from interpretRequestKey one-to-one. +const createRequestKeydownHandler = + ({ controller, optionsRef, editorRef, dispatch }: RequestKeydownDeps) => (event: KeyboardEvent) => { const scopedOptionsElement = optionsRef.current?.getElement() ?? null; if ( @@ -239,7 +239,7 @@ const createAskUserKeydownHandler = } const optionsHandle = optionsRef.current; - const action = interpretAskUserKey( + const action = interpretRequestKey( { key: event.key, ctrlKey: event.ctrlKey, @@ -303,7 +303,7 @@ export const createComposerStore = (): ComposerStore => { // Imperative refs co-located with the store; not reactive. const containerRef: RefObject = { current: null }; - const optionsRef: RefObject = { current: null }; + const optionsRef: RefObject = { current: null }; const fileInputRef: RefObject = { current: null }; const globalDropRef: RefObject = { current: false }; // Permissive defaults: accept everything, no caps, platform-default blob @@ -318,7 +318,7 @@ export const createComposerStore = (): ComposerStore => { destroy: revokeAttachmentUrl, }, }; - const submitAnswersRef: RefObject<((answers: ComposerAnswerEntry[]) => void) | null> = { + const submitRequestsRef: RefObject<((requests: ComposerRequestEntry[]) => void) | null> = { current: null, }; const commandSelectRef: RefObject<(() => void) | null> = { current: null }; @@ -326,7 +326,7 @@ export const createComposerStore = (): ComposerStore => { // Canonical machine states; the snapshot mirrors them on every update. let attachmentState = INITIAL_ATTACHMENT_STATE; - let askUserMachine = INITIAL_ASK_USER_STATE; + let requestsMachine = INITIAL_REQUEST_STATE; let snapshot: ComposerState; // --- Setters @@ -414,12 +414,12 @@ export const createComposerStore = (): ComposerStore => { notify(); }; - // --- Ask-user - // The execute half of the ask-user flow: replay a transition's effects + // --- Requests + // The execute half of the request flow: replay a transition's effects // against the editor controller, the options handle, and the submit // callback. Input-content state is the editor engine's own job — engines // report programmatic setText/clear through their update path. - const executeAskUserEffects = (effects: AskUserEffect[]) => { + const executeRequestEffects = (effects: RequestEffect[]) => { for (const effect of effects) { switch (effect.type) { case "clear-input": @@ -441,68 +441,68 @@ export const createComposerStore = (): ComposerStore => { case "reset-highlight": optionsRef.current?.resetHighlight(); break; - case "submit-answers": - submitAnswersRef.current?.(effect.answers); + case "submit-requests": + submitRequestsRef.current?.(effect.requests); break; } } }; - const dispatchAskUser = (action: AskUserAction) => { - const questions = snapshot.askUser.questions; - if (!questions || questions.length === 0) return; - const { next, effects } = transitionAskUser(askUserMachine, questions, action); - if (next !== askUserMachine) { - askUserMachine = next; + const dispatchRequests = (action: RequestAction) => { + const requests = snapshot.requests.items; + if (!requests || requests.length === 0) return; + const { next, effects } = transitionRequests(requestsMachine, requests, action); + if (next !== requestsMachine) { + requestsMachine = next; snapshot = { ...snapshot, - askUser: { - ...snapshot.askUser, + requests: { + ...snapshot.requests, step: next.step, - answers: next.answers, - isLastStep: isLastStep(next, questions), + drafts: next.drafts, + isLastStep: isLastStep(next, requests), }, }; notify(); } - executeAskUserEffects(effects); + executeRequestEffects(effects); }; - const setQuestions = (questions: AskUserQuestion[] | null) => { - if (snapshot.askUser.questions === questions) return; - askUserMachine = INITIAL_ASK_USER_STATE; + const setRequests = (requests: ComposerRequest[] | null) => { + if (snapshot.requests.items === requests) return; + requestsMachine = INITIAL_REQUEST_STATE; snapshot = { ...snapshot, - askUser: { - ...snapshot.askUser, - active: questions != null, + requests: { + ...snapshot.requests, + active: requests != null, // Sticky, same as commands: cleared by finalizePanelClose() after the exit. - present: questions != null || snapshot.askUser.present, - questions, - step: askUserMachine.step, - answers: askUserMachine.answers, - isLastStep: questions ? isLastStep(askUserMachine, questions) : false, - isSingle: questions ? questions.length === 1 : false, + present: requests != null || snapshot.requests.present, + items: requests, + step: requestsMachine.step, + drafts: requestsMachine.drafts, + isLastStep: requests ? isLastStep(requestsMachine, requests) : false, + isSingle: requests ? requests.length === 1 : false, }, }; notify(); }; - // Ask-user mode: while questions are active the options own the keyboard. + // Request mode: while requests are active the options own the keyboard. // Entering moves DOM focus onto the highlighted option (roving tabindex), // and the keydown listener sits at the document, scoped by containment // (isEventForComposer) — so keys keep flowing after a chrome click drops // focus to , while a second composer's editor/options never hear them. - const activateAskUser = () => { - const handleKeyDown = createAskUserKeydownHandler({ + const activateRequests = () => { + const handleKeyDown = createRequestKeydownHandler({ controller, optionsRef, editorRef, - dispatch: dispatchAskUser, + dispatch: dispatchRequests, }); document.addEventListener("keydown", handleKeyDown); - // The Options part renders in reaction to setQuestions' notify — one + // The Options part renders in reaction to setRequests' notify — one // commit after this call — so the entry focus defers a frame. const frame = requestAnimationFrame(() => { optionsRef.current?.focusHighlighted(); @@ -536,21 +536,21 @@ export const createComposerStore = (): ComposerStore => { fileInputRef, globalDropRef, }, - askUser: { + requests: { active: false, present: false, - questions: null, - step: askUserMachine.step, - answers: askUserMachine.answers, + items: null, + step: requestsMachine.step, + drafts: requestsMachine.drafts, isLastStep: false, isSingle: false, - toggleOption: (label) => dispatchAskUser({ type: "toggle-option", label }), + toggleOption: (label) => dispatchRequests({ type: "toggle-option", label }), continueStep: (freeText) => - dispatchAskUser({ type: "continue-step", freeText: freeText ?? "" }), - dismissStep: () => dispatchAskUser({ type: "dismiss-step" }), - clearSelections: () => dispatchAskUser({ type: "clear-selections" }), - goBack: () => dispatchAskUser({ type: "step-back", currentText: controller.getText() }), - goNext: () => dispatchAskUser({ type: "step-forward", currentText: controller.getText() }), + dispatchRequests({ type: "continue-step", freeText: freeText ?? "" }), + dismissStep: () => dispatchRequests({ type: "dismiss-step" }), + clearSelections: () => dispatchRequests({ type: "clear-selections" }), + goBack: () => dispatchRequests({ type: "step-back", currentText: controller.getText() }), + goNext: () => dispatchRequests({ type: "step-forward", currentText: controller.getText() }), optionsRef, }, }; @@ -563,14 +563,14 @@ export const createComposerStore = (): ComposerStore => { // the Panel once its close animation finishes, so the last content stays mounted // (and keeps animating) until then. Identity-guarded so it no-ops when nothing changed. const finalizePanelClose = () => { - const { commands, askUser } = snapshot; - if (commands.present === commands.active && askUser.present === askUser.active) return; + const { commands, requests } = snapshot; + if (commands.present === commands.active && requests.present === requests.active) return; snapshot = { ...snapshot, commands: commands.present === commands.active ? commands : { ...commands, present: commands.active }, - askUser: - askUser.present === askUser.active ? askUser : { ...askUser, present: askUser.active }, + requests: + requests.present === requests.active ? requests : { ...requests, present: requests.active }, }; notify(); }; @@ -580,7 +580,7 @@ export const createComposerStore = (): ComposerStore => { // revoke attachment object URLs, then restore the pristine snapshot. const reset = () => { dispatchAttachments({ type: "reset" }); - askUserMachine = INITIAL_ASK_USER_STATE; + requestsMachine = INITIAL_REQUEST_STATE; commandSelectRef.current = null; snapshot = initialSnapshot; notify(); @@ -601,10 +601,10 @@ export const createComposerStore = (): ComposerStore => { listboxId: "", moveHighlight, setHighlight, - setQuestions, + setRequests, setDragging, resetAttachments: () => dispatchAttachments({ type: "reset" }), - activateAskUser, + activateRequests, finalizePanelClose, reset, editorRef, @@ -612,7 +612,7 @@ export const createComposerStore = (): ComposerStore => { registerEditor, containerRef, attachmentConfigRef, - submitAnswersRef, + submitRequestsRef, stopGeneratingRef, commandSelectRef, }; @@ -656,7 +656,7 @@ export const useComposerStore = ( // Subscribe to composer state from inside the tree. With a selector, the // component re-renders only when the selected value changes identity (slices // are identity-stable): -// const askUser = useComposer((composer) => composer.askUser); +// const requests = useComposer((composer) => composer.requests); // Without one, it returns the full snapshot and re-renders on any change. export const useComposer = ( selector?: (composer: ComposerState) => Selected, diff --git a/packages/ui/src/composer/textarea.tsx b/packages/ui/src/composer/textarea.tsx index 45e57772..991e48fd 100644 --- a/packages/ui/src/composer/textarea.tsx +++ b/packages/ui/src/composer/textarea.tsx @@ -136,7 +136,7 @@ export const ComposerTextarea = ({
EngineDependencies) => { const plainText = getPlainText(nextDoc); refreshHasContent(); - // Single-select questions clear their selection once the user starts + // Single-select requests clear their selection once the user starts // typing a free-text answer. if (docChanged && plainText.trim().length > 0) { - const { askUser } = store.getSnapshot(); - const currentQuestion = askUser.questions?.[askUser.step]; - if (askUser.questions && !currentQuestion?.multiSelect) askUser.clearSelections(); - askUser.optionsRef.current?.clearHighlight(); + const { requests } = store.getSnapshot(); + const currentRequest = requests.items?.[requests.step]; + if (requests.items && !currentRequest?.multiSelect) requests.clearSelections(); + requests.optionsRef.current?.clearHighlight(); } mirrorCommandState(); @@ -434,7 +434,7 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => { { key: event.key, shiftKey: event.shiftKey }, { isCommandListOpen: commandState.isOpen, - hasActiveAskUser: (store.getSnapshot().askUser.questions?.length ?? 0) > 0, + hasActiveRequests: (store.getSnapshot().requests.items?.length ?? 0) > 0, isEditorEmpty: getPlainText(doc) === "", hasAttachments: store.getSnapshot().attachments.items.length > 0, submitOn: options.submitOn, @@ -476,17 +476,17 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => { } return; } - case "ask-user-arrow": { + case "request-arrow": { // navigate() moves DOM focus onto the newly highlighted option // (roving tabindex) — never blur to . At the list boundary // (null) focus stays in the editor. event.preventDefault(); - store.getSnapshot().askUser.optionsRef.current?.navigate(action.direction); + store.getSnapshot().requests.optionsRef.current?.navigate(action.direction); return; } - case "ask-user-dismiss": { + case "request-dismiss": { event.preventDefault(); - store.getSnapshot().askUser.dismissStep(); + store.getSnapshot().requests.dismissStep(); return; } case "remove-last-attachment": { @@ -529,7 +529,7 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => { }; const handleFocus = () => { - getDependencies().store.getSnapshot().askUser.optionsRef.current?.clearHighlight(); + getDependencies().store.getSnapshot().requests.optionsRef.current?.clearHighlight(); if (!root) return; const ownerDocument = root.ownerDocument; ownerDocument.addEventListener("selectionchange", handleSelectionChange); diff --git a/packages/ui/src/message-utils.ts b/packages/ui/src/message-utils.ts index e8c07a99..57ac6498 100644 --- a/packages/ui/src/message-utils.ts +++ b/packages/ui/src/message-utils.ts @@ -130,6 +130,6 @@ export const getFileParts = (segments: readonly MessageSegment[]): FilePart[] => .filter((s): s is MessageSegment & { type: "file" } => s.type === "file") .flatMap((s) => s.parts); -// Chain/reasoning/source extraction (and ask-user extraction) is the consuming +// Chain/reasoning/source extraction (and request extraction) is the consuming // app's concern — grouping conventions, header formats, and dedup rules are // product policy. This package ships only the generic segmentation above. diff --git a/packages/ui/src/thread/geometry.ts b/packages/ui/src/thread/geometry.ts index 0f2f4f95..8d890b20 100644 --- a/packages/ui/src/thread/geometry.ts +++ b/packages/ui/src/thread/geometry.ts @@ -57,7 +57,7 @@ export const queryDock = (root: HTMLElement): HTMLElement | null => * root's bottom, plus the content gap. Measured against the root's bottom * rather than the slot's own height, so a dock that floats above the bottom * edge still reserves the space beneath it. Parts that must NOT reserve space - * — the command-list / ask-user panel, the scroll button — are positioned out + * — the command-list / ask panel, the scroll button — are positioned out * of the slot's flow. Returns null when the slot isn't mounted yet. */ export const measureDockInset = (root: HTMLElement): number | null => { diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index da6de285..2a423f08 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -84,10 +84,6 @@ export type ChatMessage { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; - if (!question) return null; - const entry = askUser.answers.get(askUser.step); - const selected = entry?.selected ?? new Set(); +const AskHarness = () => { + const requests = useComposer((composer) => composer.requests); + const request = requests.items?.[requests.step]; + if (!request) return null; + const draft = requests.drafts.get(requests.step); + const selected = draft?.selected ?? new Set(); return ( - - - {question.question} - - - {question.options.map((option) => ( - + + {request.label} + + + {request.options.map((option) => ( + askUser.toggleOption(option.label)} + onSelect={() => requests.toggleOption(option.label)} > - {option.label} - + {option.label} + ))} - - + + ); }; -// activateAskUser defers focus/listener attachment one frame past the commit +// activateRequests defers focus/listener attachment one frame past the commit // that mounts the options. const flushFrames = async () => { await act(async () => { @@ -65,17 +67,17 @@ const flushFrames = async () => { }); }; -describe("ask-user a11y", () => { +describe("ask a11y", () => { test("focus lands in a labelled radiogroup, arrows rove, and the question is answerable by keyboard alone", async () => { const submitted: ComposerSubmitData[] = []; const { container, unmount } = render( { submitted.push(data); }} > - + , ); await flushFrames(); @@ -99,12 +101,12 @@ describe("ask-user a11y", () => { await expectNoAxeViolations(container); // Enter on a single-select selects AND advances — with one question, the - // answers submit. The full flow just happened without a mouse. + // entries submit. The full flow just happened without a mouse. fireEvent.keyDown(group, { key: "Enter" }); expect(submitted).toEqual([ { - kind: "answers", - answers: [{ question: "Which database should we use?", option: "MongoDB" }], + kind: "requests", + requests: [{ id: "database", selected: ["MongoDB"] }], }, ]); @@ -114,8 +116,8 @@ describe("ask-user a11y", () => { test("multi-select checkboxes toggle aria-checked in place with Enter and Space", async () => { const { container, unmount } = render( - {}}> - + {}}> + , ); await flushFrames(); @@ -144,8 +146,8 @@ describe("ask-user a11y", () => { test("arrows still navigate after a chrome click drops focus to body", async () => { const { unmount } = render( - {}}> - + {}}> + , ); await flushFrames(); @@ -168,8 +170,8 @@ describe("ask-user a11y", () => { test("navigating past the list boundary keeps focus instead of dropping to body", async () => { const { unmount } = render( - {}}> - + {}}> + , ); await flushFrames(); diff --git a/packages/ui/tests/composer-ask-user-machine.test.ts b/packages/ui/tests/composer-ask-user-machine.test.ts deleted file mode 100644 index f83ab13b..00000000 --- a/packages/ui/tests/composer-ask-user-machine.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type AskUserState, - compileAnswers, - INITIAL_ASK_USER_STATE, - isLastStep, - transitionAskUser, -} from "../src/composer/ask-user-machine"; -import type { AskUserQuestion } from "../src/composer/types"; - -const questions: AskUserQuestion[] = [ - { question: "Q1?", options: [{ label: "A", description: "a" }] }, - { - question: "Q2?", - options: [ - { label: "B", description: "b" }, - { label: "C", description: "c" }, - ], - multiSelect: true, - }, -]; - -describe("transitionAskUser", () => { - test("select-option on single-select advances a step and clears input", () => { - const { next, effects } = transitionAskUser(INITIAL_ASK_USER_STATE, questions, { - type: "select-option", - label: "A", - }); - - expect(next.step).toBe(1); - expect(next.answers.get(0)?.selected.has("A")).toBe(true); - expect(effects.map((e) => e.type)).toEqual(["clear-input", "reset-highlight", "focus-options"]); - }); - - test("select-option on the last step compiles and submits", () => { - const onLast: AskUserState = { step: 1, answers: new Map() }; - const { next, effects } = transitionAskUser(onLast, questions, { - type: "select-option", - label: "B", - }); - - // Multi-select never auto-advances on select — it toggles. - expect(next.step).toBe(1); - expect(effects).toEqual([]); - expect(next.answers.get(1)?.selected.has("B")).toBe(true); - }); - - test("continue-step on the last step emits submit-answers with compiled payload", () => { - const state: AskUserState = { - step: 1, - answers: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]), - }; - const withToggle = transitionAskUser(state, questions, { type: "toggle-option", label: "B" }); - const { effects } = transitionAskUser(withToggle.next, questions, { - type: "continue-step", - freeText: "extra context", - }); - - const submit = effects.find((e) => e.type === "submit-answers"); - expect(submit).toBeDefined(); - if (submit?.type !== "submit-answers") throw new Error("unreachable"); - expect(submit.answers).toEqual([ - { question: "Q1?", option: "A" }, - { question: "Q2?", options: ["B"], text: "extra context" }, - ]); - expect(effects.at(-1)).toEqual({ type: "focus-input" }); - }); - - test("multiSelect toggle flips membership and keeps free text", () => { - const first = transitionAskUser({ step: 1, answers: new Map() }, questions, { - type: "toggle-option", - label: "B", - }); - const second = transitionAskUser(first.next, questions, { type: "toggle-option", label: "B" }); - expect(second.next.answers.get(1)?.selected.size).toBe(0); - }); - - test("dismiss-step skips the answer entirely", () => { - const { next } = transitionAskUser(INITIAL_ASK_USER_STATE, questions, { - type: "dismiss-step", - }); - expect(next.step).toBe(1); - expect(next.answers.has(0)).toBe(false); - }); - - test("step-back preserves in-progress free text and restores the target's", () => { - const state: AskUserState = { - step: 1, - answers: new Map([[0, { selected: new Set(), freeText: "saved" }]]), - }; - const { next, effects } = transitionAskUser(state, questions, { - type: "step-back", - currentText: "typing on q2", - }); - - expect(next.step).toBe(0); - expect(next.answers.get(1)?.freeText).toBe("typing on q2"); - expect(effects[0]).toEqual({ type: "set-input-text", text: "saved" }); - }); - - test("step navigation clamps at the ends", () => { - const atStart = transitionAskUser(INITIAL_ASK_USER_STATE, questions, { - type: "step-back", - currentText: "", - }); - expect(atStart.next).toBe(INITIAL_ASK_USER_STATE); - expect(atStart.effects).toEqual([]); - }); -}); - -describe("compileAnswers", () => { - test("unanswered questions compile to bare entries", () => { - expect(compileAnswers(INITIAL_ASK_USER_STATE, questions)).toEqual([ - { question: "Q1?" }, - { question: "Q2?" }, - ]); - }); - - test("free text on single-select compiles to text entry", () => { - const state: AskUserState = { - step: 0, - answers: new Map([[0, { selected: new Set(), freeText: " custom " }]]), - }; - expect(compileAnswers(state, questions)[0]).toEqual({ question: "Q1?", text: "custom" }); - }); -}); - -describe("isLastStep", () => { - test("reports the final question", () => { - expect(isLastStep({ step: 0, answers: new Map() }, questions)).toBe(false); - expect(isLastStep({ step: 1, answers: new Map() }, questions)).toBe(true); - }); -}); diff --git a/packages/ui/tests/composer-escape.test.tsx b/packages/ui/tests/composer-escape.test.tsx index fecfdb05..16f22def 100644 --- a/packages/ui/tests/composer-escape.test.tsx +++ b/packages/ui/tests/composer-escape.test.tsx @@ -2,10 +2,10 @@ import { describe, expect, mock, test } from "bun:test"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { useState } from "react"; import { createPortal } from "react-dom"; -import { AskUser } from "../src/ask-user"; +import { Ask } from "../src/ask"; import { Composer } from "../src/composer"; import { useComposer } from "../src/composer/store"; -import type { AskUserQuestion, ComposerSubmitData } from "../src/composer/types"; +import type { ComposerRequest, ComposerSubmitData } from "../src/composer/types"; import { Tabs } from "../src/tabs"; // Escape stops a generating composer only when pressed inside that composer. @@ -28,30 +28,34 @@ const ChatComposer = ({ const pressEscape = (target: Element | Document) => act(() => void fireEvent.keyDown(target, { key: "Escape" })); -const QUESTIONS: AskUserQuestion[] = [ - { question: "Which database?", options: [{ label: "PostgreSQL" }, { label: "SQLite" }] }, +const REQUESTS: ComposerRequest[] = [ + { + id: "database", + label: "Which database?", + options: [{ label: "PostgreSQL" }, { label: "SQLite" }], + }, ]; -const AskUserOptions = () => { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; - if (!question) return null; +const RequestOptions = () => { + const requests = useComposer((composer) => composer.requests); + const request = requests.items?.[requests.step]; + if (!request) return null; return ( - - {question.options.map((option) => ( - + {request.options.map((option) => ( + askUser.toggleOption(option.label)} + onSelect={() => requests.toggleOption(option.label)} > {option.label} - + ))} - + ); }; -// activateAskUser moves focus into the options one frame past the commit. +// activateRequests moves focus into the options one frame past the commit. const flushFrames = () => act( () => @@ -120,13 +124,13 @@ describe("Escape stops generation", () => { expect(notPrevented).toBe(true); }); - test("not while an ask-user question is open; Escape dismisses its step", async () => { + test("not while a request is open; Escape dismisses its step", async () => { const onStop = mock(); const submitted: ComposerSubmitData[] = []; render( - void submitted.push(data)}> + void submitted.push(data)}> - + , ); @@ -134,7 +138,7 @@ describe("Escape stops generation", () => { pressEscape(screen.getByRole("radio", { name: "PostgreSQL" })); expect(onStop).not.toHaveBeenCalled(); - expect(submitted.at(-1)?.kind).toBe("answers"); + expect(submitted.at(-1)?.kind).toBe("requests"); }); test("not when the consumer's onKeyDown skips it", () => { diff --git a/packages/ui/tests/composer-keyboard.test.ts b/packages/ui/tests/composer-keyboard.test.ts index d8da47a2..565c0103 100644 --- a/packages/ui/tests/composer-keyboard.test.ts +++ b/packages/ui/tests/composer-keyboard.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { interpretAskUserKey, interpretEditorKey } from "../src/composer/keyboard"; +import { interpretEditorKey, interpretRequestKey } from "../src/composer/keyboard"; const baseContext = { isCommandListOpen: false, - hasActiveAskUser: false, + hasActiveRequests: false, isEditorEmpty: false, hasAttachments: false, }; @@ -26,10 +26,10 @@ describe("interpretEditorKey", () => { }); }); - test("ask-user arrows take over when active", () => { - const ctx = { ...baseContext, hasActiveAskUser: true }; + test("request arrows take over when active", () => { + const ctx = { ...baseContext, hasActiveRequests: true }; expect(interpretEditorKey({ key: "ArrowUp", shiftKey: false }, ctx)).toEqual({ - type: "ask-user-arrow", + type: "request-arrow", direction: -1, }); }); @@ -55,8 +55,8 @@ describe("interpretEditorKey", () => { }); }); -describe("interpretAskUserKey", () => { - const event = (key: string, extra: Partial[0]> = {}) => ({ +describe("interpretRequestKey", () => { + const event = (key: string, extra: Partial[0]> = {}) => ({ key, ctrlKey: false, metaKey: false, @@ -66,39 +66,39 @@ describe("interpretAskUserKey", () => { }); test("escape dismisses regardless of highlight", () => { - expect(interpretAskUserKey(event("Escape"), { hasHighlight: false })).toEqual({ + expect(interpretRequestKey(event("Escape"), { hasHighlight: false })).toEqual({ type: "dismiss-step", }); }); test("without highlight, other keys pass through", () => { - expect(interpretAskUserKey(event("ArrowDown"), { hasHighlight: false })).toBeNull(); + expect(interpretRequestKey(event("ArrowDown"), { hasHighlight: false })).toBeNull(); }); test("with highlight, arrows navigate and enter selects", () => { - expect(interpretAskUserKey(event("ArrowDown"), { hasHighlight: true })).toEqual({ + expect(interpretRequestKey(event("ArrowDown"), { hasHighlight: true })).toEqual({ type: "navigate-options", direction: 1, }); - expect(interpretAskUserKey(event("Enter"), { hasHighlight: true })).toEqual({ + expect(interpretRequestKey(event("Enter"), { hasHighlight: true })).toEqual({ type: "select-option", }); - expect(interpretAskUserKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({ + expect(interpretRequestKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({ type: "go-back", }); }); test("printable characters route back to the input", () => { - expect(interpretAskUserKey(event("x"), { hasHighlight: true })).toEqual({ + expect(interpretRequestKey(event("x"), { hasHighlight: true })).toEqual({ type: "insert-character", character: "x", }); - expect(interpretAskUserKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull(); + expect(interpretRequestKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull(); }); test("defaultPrevented events are ignored", () => { expect( - interpretAskUserKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }), + interpretRequestKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }), ).toBeNull(); }); }); @@ -142,10 +142,10 @@ describe("interpretEditorKey: submitOn", () => { }); }); -describe("interpretAskUserKey: Space selects", () => { +describe("interpretRequestKey: Space selects", () => { test("Space toggles the highlighted option (APG radio/checkbox)", () => { expect( - interpretAskUserKey( + interpretRequestKey( { key: " ", ctrlKey: false, metaKey: false, altKey: false, defaultPrevented: false }, { hasHighlight: true }, ), @@ -153,21 +153,21 @@ describe("interpretAskUserKey: Space selects", () => { }); }); -describe("interpretEditorKey: ask-user dismiss", () => { +describe("interpretEditorKey: request dismiss", () => { test("editor-focused Escape dismisses the active question", () => { expect( interpretEditorKey( { key: "Escape", shiftKey: false }, - { ...baseContext, hasActiveAskUser: true }, + { ...baseContext, hasActiveRequests: true }, ), - ).toEqual({ type: "ask-user-dismiss" }); + ).toEqual({ type: "request-dismiss" }); }); test("command list still owns Escape when both are active", () => { expect( interpretEditorKey( { key: "Escape", shiftKey: false }, - { ...baseContext, hasActiveAskUser: true, isCommandListOpen: true }, + { ...baseContext, hasActiveRequests: true, isCommandListOpen: true }, ), ).toEqual({ type: "command-close" }); }); diff --git a/packages/ui/tests/composer-request-machine.test.ts b/packages/ui/tests/composer-request-machine.test.ts new file mode 100644 index 00000000..d4f56ab8 --- /dev/null +++ b/packages/ui/tests/composer-request-machine.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { + compileRequests, + INITIAL_REQUEST_STATE, + isLastStep, + type RequestState, + transitionRequests, +} from "../src/composer/request-machine"; +import type { ComposerRequest } from "../src/composer/types"; + +const requests: ComposerRequest[] = [ + { id: "q1", label: "Q1?", options: [{ label: "A", description: "a" }] }, + { + id: "q2", + label: "Q2?", + options: [ + { label: "B", description: "b" }, + { label: "C", description: "c" }, + ], + multiSelect: true, + }, +]; + +describe("transitionRequests", () => { + test("select-option on single-select advances a step and clears input", () => { + const { next, effects } = transitionRequests(INITIAL_REQUEST_STATE, requests, { + type: "select-option", + label: "A", + }); + + expect(next.step).toBe(1); + expect(next.drafts.get(0)?.selected.has("A")).toBe(true); + expect(effects.map((e) => e.type)).toEqual(["clear-input", "reset-highlight", "focus-options"]); + }); + + test("select-option on the last step compiles and submits", () => { + const onLast: RequestState = { step: 1, drafts: new Map() }; + const { next, effects } = transitionRequests(onLast, requests, { + type: "select-option", + label: "B", + }); + + // Multi-select never auto-advances on select — it toggles. + expect(next.step).toBe(1); + expect(effects).toEqual([]); + expect(next.drafts.get(1)?.selected.has("B")).toBe(true); + }); + + test("continue-step on the last step emits submit-requests with compiled payload", () => { + const state: RequestState = { + step: 1, + drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]), + }; + const withToggle = transitionRequests(state, requests, { type: "toggle-option", label: "B" }); + const { effects } = transitionRequests(withToggle.next, requests, { + type: "continue-step", + freeText: "extra context", + }); + + const submit = effects.find((e) => e.type === "submit-requests"); + expect(submit).toBeDefined(); + if (submit?.type !== "submit-requests") throw new Error("unreachable"); + expect(submit.requests).toEqual([ + { id: "q1", selected: ["A"] }, + { id: "q2", selected: ["B"], text: "extra context" }, + ]); + expect(effects.at(-1)).toEqual({ type: "focus-input" }); + }); + + test("multiSelect toggle flips membership and keeps free text", () => { + const first = transitionRequests({ step: 1, drafts: new Map() }, requests, { + type: "toggle-option", + label: "B", + }); + const second = transitionRequests(first.next, requests, { type: "toggle-option", label: "B" }); + expect(second.next.drafts.get(1)?.selected.size).toBe(0); + }); + + test("dismiss-step skips the draft entirely", () => { + const { next } = transitionRequests(INITIAL_REQUEST_STATE, requests, { + type: "dismiss-step", + }); + expect(next.step).toBe(1); + expect(next.drafts.has(0)).toBe(false); + }); + + test("step-back preserves in-progress free text and restores the target's", () => { + const state: RequestState = { + step: 1, + drafts: new Map([[0, { selected: new Set(), freeText: "saved" }]]), + }; + const { next, effects } = transitionRequests(state, requests, { + type: "step-back", + currentText: "typing on q2", + }); + + expect(next.step).toBe(0); + expect(next.drafts.get(1)?.freeText).toBe("typing on q2"); + expect(effects[0]).toEqual({ type: "set-input-text", text: "saved" }); + }); + + test("step navigation clamps at the ends", () => { + const atStart = transitionRequests(INITIAL_REQUEST_STATE, requests, { + type: "step-back", + currentText: "", + }); + expect(atStart.next).toBe(INITIAL_REQUEST_STATE); + expect(atStart.effects).toEqual([]); + }); +}); + +describe("compileRequests", () => { + test("skipped requests compile to empty-selection entries keyed by id", () => { + expect(compileRequests(INITIAL_REQUEST_STATE, requests)).toEqual([ + { id: "q1", selected: [] }, + { id: "q2", selected: [] }, + ]); + }); + + test("free text on single-select compiles to a text entry", () => { + const state: RequestState = { + step: 0, + drafts: new Map([[0, { selected: new Set(), freeText: " custom " }]]), + }; + expect(compileRequests(state, requests)[0]).toEqual({ id: "q1", selected: [], text: "custom" }); + }); + + test("text is omitted entirely when the user typed nothing", () => { + const state: RequestState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]), + }; + const entry = compileRequests(state, requests)[0]; + expect(entry).toBeDefined(); + expect(entry && "text" in entry).toBe(false); + }); + + test("option values are echoed when present, labels as fallback", () => { + const valued: ComposerRequest[] = [ + { + id: "framework", + label: "Which framework?", + options: [{ value: "next", label: "Next.js" }, { label: "Remix" }], + multiSelect: true, + }, + ]; + // Toggle keys are labels (how the widget wires Option value today). + const state: RequestState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["Next.js", "Remix"]), freeText: "" }]]), + }; + expect(compileRequests(state, valued)[0]).toEqual({ + id: "framework", + selected: ["next", "Remix"], + }); + }); + + test("keys already stored as values resolve to the same values", () => { + const valued: ComposerRequest[] = [ + { id: "db", label: "Database?", options: [{ value: "pg", label: "PostgreSQL" }] }, + ]; + const state: RequestState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["pg"]), freeText: "" }]]), + }; + expect(compileRequests(state, valued)[0]).toEqual({ id: "db", selected: ["pg"] }); + }); +}); + +describe("isLastStep", () => { + test("reports the final request", () => { + expect(isLastStep({ step: 0, drafts: new Map() }, requests)).toBe(false); + expect(isLastStep({ step: 1, drafts: new Map() }, requests)).toBe(true); + }); +}); diff --git a/packages/ui/tests/composer-scroll-containment.test.tsx b/packages/ui/tests/composer-scroll-containment.test.tsx index f918dd5c..59069a1d 100644 --- a/packages/ui/tests/composer-scroll-containment.test.tsx +++ b/packages/ui/tests/composer-scroll-containment.test.tsx @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { act, fireEvent, render, screen } from "@testing-library/react"; -import { AskUser } from "../src/ask-user"; +import { Ask } from "../src/ask"; import { Composer } from "../src/composer"; import { scrollCaretIntoView } from "../src/composer/editor-dom"; import { useComposer } from "../src/composer/store"; -import type { AskUserQuestion } from "../src/composer/types"; +import type { ComposerRequest } from "../src/composer/types"; import { scrollIntoViewWithin } from "../src/internal/scroll-into-view-within"; // A composer embedded in a scrolling page scrolls only its own scroll areas: -// programmatic writes and the ask-user flow's focus moves must leave the page +// programmatic writes and the request flow's focus moves must leave the page // where it is (CHAT-42). // happy-dom has no layout, so each element gets a stubbed box. @@ -175,35 +175,39 @@ describe("scrollIntoViewWithin", () => { }); // --------------------------------------------------------------------------- -// The ask-user flow's own focus moves +// The request flow's own focus moves // --------------------------------------------------------------------------- -const QUESTIONS: AskUserQuestion[] = [ - { question: "Which database?", options: [{ label: "PostgreSQL" }, { label: "SQLite" }] }, +const REQUESTS: ComposerRequest[] = [ + { + id: "database", + label: "Which database?", + options: [{ label: "PostgreSQL" }, { label: "SQLite" }], + }, ]; -const AskUserOptions = () => { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; - if (!question) return null; +const RequestOptions = () => { + const requests = useComposer((composer) => composer.requests); + const request = requests.items?.[requests.step]; + if (!request) return null; return ( - - - {question.options.map((option) => ( - + + {request.options.map((option) => ( + askUser.toggleOption(option.label)} + onSelect={() => requests.toggleOption(option.label)} > {option.label} - + ))} - - + + ); }; -// activateAskUser moves focus into the options one frame past the commit. +// activateRequests moves focus into the options one frame past the commit. const flushFrames = () => act( () => @@ -212,7 +216,7 @@ const flushFrames = () => }), ); -describe("ask-user focus moves keep the page in place", () => { +describe("request focus moves keep the page in place", () => { const focus = HTMLElement.prototype.focus; let calls: { element: HTMLElement; options?: FocusOptions }[] = []; @@ -230,9 +234,9 @@ describe("ask-user focus moves keep the page in place", () => { const renderQuestion = async (container?: HTMLElement) => { render( - {}}> + {}}> - + , { container }, ); diff --git a/packages/ui/tests/composer-store-present.test.ts b/packages/ui/tests/composer-store-present.test.ts index dbdc58e5..289606dd 100644 --- a/packages/ui/tests/composer-store-present.test.ts +++ b/packages/ui/tests/composer-store-present.test.ts @@ -33,18 +33,18 @@ describe("composer store: sticky panel presence", () => { expect(store.getSnapshot().commands.present).toBe(false); }); - test("ask-user presence is sticky the same way", () => { + test("request presence is sticky the same way", () => { const store = createComposerStore(); - store.setQuestions([{ question: "Pick one", options: [{ label: "A" }] }]); - expect(store.getSnapshot().askUser.active).toBe(true); - expect(store.getSnapshot().askUser.present).toBe(true); + store.setRequests([{ id: "pick", label: "Pick one", options: [{ label: "A" }] }]); + expect(store.getSnapshot().requests.active).toBe(true); + expect(store.getSnapshot().requests.present).toBe(true); - store.setQuestions(null); - expect(store.getSnapshot().askUser.active).toBe(false); - expect(store.getSnapshot().askUser.present).toBe(true); + store.setRequests(null); + expect(store.getSnapshot().requests.active).toBe(false); + expect(store.getSnapshot().requests.present).toBe(true); store.finalizePanelClose(); - expect(store.getSnapshot().askUser.present).toBe(false); + expect(store.getSnapshot().requests.present).toBe(false); }); test("reset clears presence", () => { From 7e3bc0c58b64c9be6386febbafada43cb07bbbaf Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Wed, 26 Aug 2026 14:41:05 +0300 Subject: [PATCH 2/5] fix(chat): tighten request identity, empty-list handling, and event scoping --- .changeset/generic-requests.md | 7 ++++ CHAT_ARCHITECTURE.md | 2 +- COMPOSER.md | 16 ++++---- components/ai/ask.tsx | 38 +++++++++++++++--- components/ai/composer.tsx | 39 +++++++++++-------- .../composer/demos/approval-flow.tsx | 9 +++-- .../docs/primitives/composer/demos/ask.tsx | 10 +++-- packages/ui/src/ask/ask.tsx | 4 +- packages/ui/src/composer/request-machine.ts | 7 +++- packages/ui/src/composer/root.tsx | 11 ++++-- packages/ui/src/composer/store.ts | 5 ++- .../ui/tests/composer-request-machine.test.ts | 21 ++++++++++ 12 files changed, 122 insertions(+), 47 deletions(-) diff --git a/.changeset/generic-requests.md b/.changeset/generic-requests.md index 1b66e5ec..6aea6832 100644 --- a/.changeset/generic-requests.md +++ b/.changeset/generic-requests.md @@ -21,3 +21,10 @@ Migration: | subpath `@intentface/ui/ask-user` | `@intentface/ui/ask` | | namespace `AskUser.*`, hooks `useAskUserOption(s)`, `AskUser*Props` | `Ask.*`, `useAskOption(s)`, `Ask*Props` | | attributes `data-ask-user-*` | `data-ask-*` | + +Also in this change: + +- `requests={[]}` no longer arms request mode — an empty array normalizes to `null`, so the slice can't read active with nothing to render. +- The request flow's document keydown listener no longer claims events from unrelated elements; it accepts its own editor, its own options, and the `` focus fallback it exists for. Space on an unrelated button can no longer select an option. +- `Ask.Previous` / `Ask.Next` default accessible names are now "Previous request" / "Next request" (override via `aria-label`), since the flow is no longer questions-only. +- A disabled composer's hidden form mirror is unchanged here — tracked separately in CHAT-28. diff --git a/CHAT_ARCHITECTURE.md b/CHAT_ARCHITECTURE.md index 874259f8..5fec1af5 100644 --- a/CHAT_ARCHITECTURE.md +++ b/CHAT_ARCHITECTURE.md @@ -475,7 +475,7 @@ A function-over-state hook that decides what the panel above the composer should type ComposerPanelState = | { type: "idle" } | { type: "active"; steps: ComposerStepItem[] } - | { type: "ask-user"; toolCallId: string; questions: ComposerRequest[]; isAnswered: boolean }; + | { type: "ask-user"; toolCallId: string; questions: AskUserQuestion[]; isAnswered: boolean }; ``` Logic, in order: diff --git a/COMPOSER.md b/COMPOSER.md index 3d53eb3f..b45624ee 100644 --- a/COMPOSER.md +++ b/COMPOSER.md @@ -1,6 +1,6 @@ # Composer -The chat composer — a form-shaped, headless-ish input surface that handles text, attachments, slash commands, mention chips, and questionnaires. It's a single component (`components/ai/composer.tsx`) exposed as a compound API via `Composer.X` parts. +The chat composer — a form-shaped, headless-ish input surface that handles text, attachments, slash commands, mention chips, and requests. It's a single component (`components/ai/composer.tsx`) exposed as a compound API via `Composer.X` parts. Import from `@/components/ai/composer`. @@ -44,7 +44,7 @@ type ComposerSubmitData = } | { kind: "requests"; - answers: ComposerRequestEntry[]; // produced by the questionnaire flow + requests: ComposerRequestEntry[]; // produced by the request flow }; type ComposerRequestEntry = @@ -67,7 +67,7 @@ If the editor is empty but attachments exist, `text` is set to `"Sent with attac | `onSubmit` | `(data: ComposerSubmitData) => void \| Promise` | Submit handler. | | `isSubmitting` | `boolean` | Disables `Composer.Submit` while truthy. Default `false`. | | `commands` | `ComposerCommandsMap` | Prefix → command-list config. See [Commands & chips](#commands--chips). | -| `questions` | `ComposerRequest[]` | When present, the composer enters questionnaire mode. See [Questionnaire](#questionnaire). | +| `requests` | `ComposerRequest[]` | When present, the composer enters request mode. See [Requests](#add-on-requests). | | `defaultValue` | `ComposerSnapshot` | Initial uncontrolled editor content. | | `value` | `ComposerSnapshot` | Controlled editor content. | | `onValueChange` | `(snapshot) => void` | Fires on editor change. | @@ -142,7 +142,7 @@ Button that opens the native file picker. Accepts `IconButton` props. ### Add-on: panel -The panel is the area above the editor that shows command lists, active tool progress, or a questionnaire. It auto-switches to `"command-list"` whenever a command list is open, regardless of the `value` you pass. +The panel is the area above the editor that shows command lists, active tool progress, or a request prompt. It auto-switches to `"command-list"` whenever a command list is open, regardless of the `value` you pass. #### `Composer.Panel` / `Composer.PanelItem` @@ -261,9 +261,9 @@ When the filter matches nothing, the empty row acts as the single highlighted op The default `` renderer already wires this up. -### Add-on: questionnaire +### Add-on: requests -Pass `questions` to switch the composer into structured-question mode. The default renderer covers the flow: +Pass `requests` to switch the composer into request mode — clarifying questions, tool approvals, any choice put to the user. The default renderer covers the flow: ```tsx @@ -291,9 +291,9 @@ Parts: - `Composer.AskDismiss` — skip the current question (`Esc`). - `Composer.AskContinue` — advance / submit (`Enter`). Auto-toggles label between "Continue" and "Submit". -Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between questions, **Esc** dismiss, printable keys type free-text. +Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between requests, **Esc** dismiss, printable keys type free-text. -On completion, `onSubmit` fires with `{ kind: "requests", requests }` where `answers` is a `ComposerRequestEntry[]` (one entry per question, in order). See [Submit data](#submit-data) for the entry shape. +On completion, `onSubmit` fires with `{ kind: "requests", requests }` where `requests` is a `ComposerRequestEntry[]` (one entry per request, in order). See [Submit data](#submit-data) for the entry shape. ## Imperative API & state diff --git a/components/ai/ask.tsx b/components/ai/ask.tsx index b572974c..d232946f 100644 --- a/components/ai/ask.tsx +++ b/components/ai/ask.tsx @@ -16,7 +16,11 @@ export type { AskOptionsHandle }; type AskRootProps = ComponentProps; const AskRoot = ({ className, ...props }: AskRootProps) => ( - + ); /** Request label text. */ @@ -24,6 +28,7 @@ type AskLabelProps = ComponentProps; const AskLabel = ({ className, ...props }: AskLabelProps) => ( @@ -33,7 +38,11 @@ const AskLabel = ({ className, ...props }: AskLabelProps) => ( type AskHeaderProps = ComponentProps; const AskHeader = ({ className, ...props }: AskHeaderProps) => ( - + ); /** Row container for `Previous`, `StepLabel`, and `Next`. */ @@ -41,6 +50,7 @@ type AskNavigationProps = ComponentProps; const AskNavigation = ({ className, ...props }: AskNavigationProps) => ( @@ -53,7 +63,11 @@ const navigationButtonClasses = type AskPreviousProps = ComponentProps; const AskPrevious = ({ className, ...props }: AskPreviousProps) => ( - + ); @@ -62,7 +76,11 @@ const AskPrevious = ({ className, ...props }: AskPreviousProps) => ( type AskNextProps = ComponentProps; const AskNext = ({ className, ...props }: AskNextProps) => ( - + ); @@ -72,6 +90,7 @@ type AskStepLabelProps = ComponentProps; const AskStepLabel = ({ className, ...props }: AskStepLabelProps) => ( @@ -85,6 +104,7 @@ type AskOptionsProps = ComponentProps & { const AskOptions = ({ multiSelect = false, className, ...props }: AskOptionsProps) => ( ; const AskOption = ({ className, ...props }: AskOptionProps) => ( ; const AskOptionContent = ({ className, ...props }: AskOptionContentProps) => ( @@ -165,7 +187,11 @@ const AskOptionContent = ({ className, ...props }: AskOptionContentProps) => ( type AskOptionLabelProps = ComponentProps; const AskOptionLabel = ({ className, ...props }: AskOptionLabelProps) => ( - + ); /** Option subtitle/description text. */ @@ -173,6 +199,7 @@ type AskOptionDescriptionProps = ComponentProps ( @@ -183,6 +210,7 @@ type AskHintsProps = ComponentProps; const AskHints = ({ className, ...props }: AskHintsProps) => ( diff --git a/components/ai/composer.tsx b/components/ai/composer.tsx index 7482a1e0..95c9cb1d 100644 --- a/components/ai/composer.tsx +++ b/components/ai/composer.tsx @@ -620,7 +620,7 @@ const ComposerAsk = () => { - {({ current, total }) => `${current} of ${total} questions`} + {({ current, total }) => `${current} of ${total} requests`} @@ -632,22 +632,27 @@ const ComposerAsk = () => { multiSelect={!!display.multiSelect} groupName={`q-${requests.step}`} > - {display.options.map((option) => ( - requests.toggleOption(option.label)} - > - - - {option.label} - {option.description && ( - {option.description} - )} - - - ))} + {display.options.map((option) => { + // One identity for key, value, lookup and toggle — so a supplied + // `value` is what the submit entry carries. + const optionValue = option.value ?? option.label; + return ( + requests.toggleOption(optionValue)} + > + + + {option.label} + {option.description && ( + {option.description} + )} + + + ); + })} )} diff --git a/content/docs/primitives/composer/demos/approval-flow.tsx b/content/docs/primitives/composer/demos/approval-flow.tsx index caa6d342..ac247b58 100644 --- a/content/docs/primitives/composer/demos/approval-flow.tsx +++ b/content/docs/primitives/composer/demos/approval-flow.tsx @@ -138,13 +138,14 @@ const Prompt = () => { className="flex flex-col" > {OPTIONS.map((option, index) => { - const selected = Boolean(draft?.selected.has(option.label)); + const optionValue = option.value ?? option.label; + const selected = Boolean(draft?.selected.has(optionValue)); return ( requests.toggleOption(option.label)} + onSelect={() => requests.toggleOption(optionValue)} className="group flex cursor-pointer items-start gap-2 rounded-[10px] p-2 outline-none transition-colors data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]" > { className="flex flex-col" > {request.options.map((option) => { - const selected = Boolean(draft?.selected.has(option.label)); + // Options are identified by their value, falling back to the label. + const optionValue = option.value ?? option.label; + const selected = Boolean(draft?.selected.has(optionValue)); return ( requests.toggleOption(option.label)} + onSelect={() => requests.toggleOption(optionValue)} className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 outline-none transition-colors data-highlighted:bg-zinc-950/5 dark:data-highlighted:bg-white/8" > {/* Decorative: the Option itself carries the radio/checkbox role. */} diff --git a/packages/ui/src/ask/ask.tsx b/packages/ui/src/ask/ask.tsx index 2e907df9..b0e33edd 100644 --- a/packages/ui/src/ask/ask.tsx +++ b/packages/ui/src/ask/ask.tsx @@ -86,7 +86,7 @@ export const AskPrevious = ({ className, render, style, ...elementProps }: AskPr useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Previous question", "data-ask-previous": "" }, elementProps] }, + { props: [{ "aria-label": "Previous request", "data-ask-previous": "" }, elementProps] }, ); /** Navigate to the next step. Default accessible name; override via aria-label. */ @@ -96,7 +96,7 @@ export const AskNext = ({ className, render, style, ...elementProps }: AskNextPr useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Next question", "data-ask-next": "" }, elementProps] }, + { props: [{ "aria-label": "Next request", "data-ask-next": "" }, elementProps] }, ); /** Step indicator. Reads the current step + total from the composer store; the diff --git a/packages/ui/src/composer/request-machine.ts b/packages/ui/src/composer/request-machine.ts index d000b50d..43d0ef97 100644 --- a/packages/ui/src/composer/request-machine.ts +++ b/packages/ui/src/composer/request-machine.ts @@ -122,8 +122,13 @@ export const compileRequests = ( const draft = state.drafts.get(index); if (!draft) return { id: request.id, selected: [] }; + // Exact identity first, then a label fallback for consumers who wire + // `value={option.label}`. Never match value-against-label, so one option's + // label cannot resolve to another's value. const selected = [...draft.selected].map((key) => { - const option = request.options.find((o) => o.label === key || o.value === key); + const option = + request.options.find((o) => (o.value ?? o.label) === key) ?? + request.options.find((o) => o.label === key); return option ? (option.value ?? option.label) : key; }); const text = draft.freeText.trim(); diff --git a/packages/ui/src/composer/root.tsx b/packages/ui/src/composer/root.tsx index 8b16d266..07c89415 100644 --- a/packages/ui/src/composer/root.tsx +++ b/packages/ui/src/composer/root.tsx @@ -107,12 +107,15 @@ export const ComposerRoot = ({ }, [store, isSubmitting]); // Sync the requests prop into the store and arm request mode (editor blur - // + document-level keyboard handling) while requests are active. + // + document-level keyboard handling) while requests are active. An empty + // array is normalized to null — otherwise the slice reads active with no + // content to render and no keyboard handler attached. + const activeRequests = requests?.length ? requests : null; useEffect(() => { - store.setRequests(requests ?? null); - if (!requests?.length) return; + store.setRequests(activeRequests); + if (!activeRequests) return; return store.activateRequests(); - }, [store, requests]); + }, [store, activeRequests]); const { getRegisteredPrefixes } = useCommandRegistry(commands); diff --git a/packages/ui/src/composer/store.ts b/packages/ui/src/composer/store.ts index d48199e7..4df88f49 100644 --- a/packages/ui/src/composer/store.ts +++ b/packages/ui/src/composer/store.ts @@ -212,7 +212,10 @@ const isEventForComposer = ( if (editableHost) return editableHost === editorDom; const optionsHost = target?.closest("[data-ask-options]"); if (optionsHost) return optionsHost === optionsElement; - return true; + // Only the body fallback the comment above describes. Anything else is a real + // element that owns its own keys — Space on an unrelated button must not + // select an option here. + return target === null || target === document.body; }; type RequestKeydownDeps = { diff --git a/packages/ui/tests/composer-request-machine.test.ts b/packages/ui/tests/composer-request-machine.test.ts index d4f56ab8..5c570f7b 100644 --- a/packages/ui/tests/composer-request-machine.test.ts +++ b/packages/ui/tests/composer-request-machine.test.ts @@ -155,6 +155,27 @@ describe("compileRequests", () => { }); }); + test("a label colliding with another option's value resolves to the selected option", () => { + const colliding: ComposerRequest[] = [ + { + id: "pick", + label: "Pick one", + options: [ + { value: "c", label: "b" }, // this label equals the next option's value + { value: "b", label: "Alpha" }, + ], + }, + ]; + // The widget stores an option's identity (value ?? label) — here, "b" for + // the second option. A cross-field lookup would match the first option's + // label instead and wrongly emit "c". + const state: RequestState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["b"]), freeText: "" }]]), + }; + expect(compileRequests(state, colliding)[0]).toEqual({ id: "pick", selected: ["b"] }); + }); + test("keys already stored as values resolve to the same values", () => { const valued: ComposerRequest[] = [ { id: "db", label: "Database?", options: [{ value: "pg", label: "PostgreSQL" }] }, From 95d79b46d7723c3b4c5c78a6dc5f4d2eefbf9aaf Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Wed, 21 Oct 2026 17:04:51 +0300 Subject: [PATCH 3/5] refactor(composer)!: fold Ask into Composer as Question parts --- .changeset/generic-requests.md | 32 +-- AGENTS.md | 2 +- CHAT_ARCHITECTURE.md | 50 ++-- COMPOSER.md | 50 ++-- components/ai/ask.tsx | 233 ------------------ components/ai/composer.tsx | 219 +++++++++++----- components/chat.tsx | 92 +++---- content/docs/handbook/accessibility.mdx | 4 +- content/docs/handbook/styling.mdx | 4 +- content/docs/index.mdx | 3 +- content/docs/primitives/composer.mdx | 62 ++--- .../composer/demos/approval-flow.tsx | 81 +++--- .../docs/primitives/composer/demos/ask.tsx | 117 --------- .../demos/{ask-flow.tsx => question-flow.tsx} | 24 +- .../primitives/composer/demos/question.tsx | 116 +++++++++ lib/ai/steps-info.ts | 36 +-- lib/ai/types.ts | 11 +- lib/playground-demo.tsx | 8 +- lib/store/playground.ts | 10 +- next.config.ts | 1 - packages/ui/README.md | 4 +- packages/ui/package.json | 4 - packages/ui/src/ask/index.parts.ts | 21 -- packages/ui/src/ask/index.ts | 24 -- packages/ui/src/composer/actions.tsx | 2 +- packages/ui/src/composer/index.parts.ts | 17 ++ packages/ui/src/composer/index.ts | 35 ++- packages/ui/src/composer/keyboard.ts | 32 +-- ...request-machine.ts => question-machine.ts} | 104 ++++---- .../{ask/ask.tsx => composer/question.tsx} | 206 ++++++++++------ packages/ui/src/composer/root.tsx | 40 +-- packages/ui/src/composer/store.ts | 168 ++++++------- packages/ui/src/composer/textarea.tsx | 2 +- packages/ui/src/composer/types.ts | 24 +- .../ui/src/composer/use-composer-editor.tsx | 24 +- packages/ui/src/message-utils.ts | 2 +- ...k.a11y.test.tsx => question.a11y.test.tsx} | 53 ++-- packages/ui/tests/composer-escape.test.tsx | 31 ++- packages/ui/tests/composer-keyboard.test.ts | 42 ++-- ...t.ts => composer-question-machine.test.ts} | 91 +++---- .../composer-scroll-containment.test.tsx | 37 ++- .../ui/tests/composer-store-present.test.ts | 16 +- packages/ui/tests/message-utils.test.ts | 2 +- tools/ask-user.ts | 4 +- 44 files changed, 1009 insertions(+), 1131 deletions(-) delete mode 100644 components/ai/ask.tsx delete mode 100644 content/docs/primitives/composer/demos/ask.tsx rename content/docs/primitives/composer/demos/{ask-flow.tsx => question-flow.tsx} (89%) create mode 100644 content/docs/primitives/composer/demos/question.tsx delete mode 100644 packages/ui/src/ask/index.parts.ts delete mode 100644 packages/ui/src/ask/index.ts rename packages/ui/src/composer/{request-machine.ts => question-machine.ts} (72%) rename packages/ui/src/{ask/ask.tsx => composer/question.tsx} (70%) rename packages/ui/tests/a11y/{ask.a11y.test.tsx => question.a11y.test.tsx} (79%) rename packages/ui/tests/{composer-request-machine.test.ts => composer-question-machine.test.ts} (62%) diff --git a/.changeset/generic-requests.md b/.changeset/generic-requests.md index 6aea6832..128d4056 100644 --- a/.changeset/generic-requests.md +++ b/.changeset/generic-requests.md @@ -2,29 +2,9 @@ "@intentface/ui": minor --- -The ask-user flow is now a generic request flow: consumer-minted ids and option values replace display-text identity, so tool approvals (and any future choice put to the user) need no new types. One term throughout — requests in, resolved requests back. - -Migration: - -| Before | After | -| --- | --- | -| prop `questions` | `requests` (entries now require an `id` you mint) | -| `AskUserQuestion` | `ComposerRequest`; field `question` → `label` | -| `AskUserOption` | `ComposerRequestOption` (gains optional `value`, echoed back; falls back to `label`) | -| `ComposerAnswerEntry` (4-variant union) | `ComposerRequestEntry` — flat `{ id, selected: string[], text? }`; `text` present only when typed; skipped entries have `selected: []` | -| `ComposerAnswersSubmit`, `kind: "answers"`, `data.answers` | `ComposerRequestsSubmit`, `kind: "requests"`, `data.requests` | -| `ComposerAskUserState`, slice `composer.askUser` | `ComposerRequestsState`, slice `composer.requests` (fields `questions` → `items`, `answers` → `drafts: Map`) | -| store `setQuestions` / `activateAskUser` / `submitAnswersRef` | `setRequests` / `activateRequests` / `submitRequestsRef` | -| `interpretAskUserKey` | `interpretRequestKey` | -| `EditorKeyContext.hasActiveAskUser` | `hasActiveRequests` | -| `EditorKeyAction` `"ask-user-arrow"` / `"ask-user-dismiss"` | `"request-arrow"` / `"request-dismiss"` | -| subpath `@intentface/ui/ask-user` | `@intentface/ui/ask` | -| namespace `AskUser.*`, hooks `useAskUserOption(s)`, `AskUser*Props` | `Ask.*`, `useAskOption(s)`, `Ask*Props` | -| attributes `data-ask-user-*` | `data-ask-*` | - -Also in this change: - -- `requests={[]}` no longer arms request mode — an empty array normalizes to `null`, so the slice can't read active with nothing to render. -- The request flow's document keydown listener no longer claims events from unrelated elements; it accepts its own editor, its own options, and the `` focus fallback it exists for. Space on an unrelated button can no longer select an option. -- `Ask.Previous` / `Ask.Next` default accessible names are now "Previous request" / "Next request" (override via `aria-label`), since the flow is no longer questions-only. -- A disabled composer's hidden form mirror is unchanged here — tracked separately in CHAT-28. +- [Composer] [Breaking] The ask-user parts move into Composer as `Composer.Question` parts, and `@intentface/ui/ask-user` is removed. Replace `AskUser.X` with `Composer.QuestionX` and `useAskUserOption(s)` with `useQuestionOption(s)`. +- [Composer] [Breaking] Questions carry an `id` you mint and a `label`, and options an optional `value` that answers echo back. Rename `AskUserQuestion` to `ComposerQuestion`, its `question` field to `label`, and give each question an `id`. +- [Composer] [Breaking] Answers are one flat `ComposerAnswer` shape, `{ id, selected, text? }`, so a tool approval is just a question. Match `data.answers` to your questions by `id` instead of by question text. +- [Composer] [Breaking] The store slice is `composer.questions`, with `items` and `drafts`, and `interpretAskUserKey` is `interpretQuestionKey`. Rename `composer.askUser` reads, and `data-ask-user-*` selectors to `data-composer-question-*`. +- [Composer] Fix an empty `questions` array arming the question flow. +- [Composer] Fix the question flow taking keys from unrelated elements, such as Space on a button outside the composer. diff --git a/AGENTS.md b/AGENTS.md index a19db30e..e8fd1d84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Two constraints follow, and both are load-bearing: - **`index.ts` and `index.parts.ts` must never carry `"use client"`.** The directive belongs on the component module one level down. Adding it to either barrel silently reintroduces the bug. - **The build must not bundle.** One file gets one top-level directive, so bundling collapses the boundary. `tsconfig.build.json` emits per-module via tsc for exactly this reason — see the comment there before changing it. -This pattern is used throughout: `Message`, `Composer`, `Attachments`, `Chip`, `Thread`, `Steps`, `Reasoning`, `Ask`. +This pattern is used throughout: `Message`, `Composer`, `Attachments`, `Chip`, `Thread`, `Steps`, `Reasoning`. A change to the package needs a changeset, and a changeset is one short line per change, not a write-up: see [`.changeset/README.md`](./.changeset/README.md). The reasoning goes in the PR body. diff --git a/CHAT_ARCHITECTURE.md b/CHAT_ARCHITECTURE.md index 5fec1af5..00e9df19 100644 --- a/CHAT_ARCHITECTURE.md +++ b/CHAT_ARCHITECTURE.md @@ -62,7 +62,7 @@ export const Composer = Object.assign(ComposerRoot, { Container, Attachments, AttachmentTrigger, ContextWindow, Actions, Placeholder, Submit, Panel, PanelItem, Textarea, - Ask, AskHints, AskDismiss, AskContinue, + Question, QuestionHints, QuestionDismiss, QuestionContinue, Commands, CommandList, CommandItems, CommandLoading, CommandEmpty, CommandDismiss, CommandItem, CommandItemIcon, CommandItemLabel, CommandItemDescription, CommandGroup, CommandGroupLabel, CommandCollection, @@ -88,7 +88,7 @@ The nesting hierarchy at a glance: - + @@ -100,9 +100,9 @@ The nesting hierarchy at a glance: - - - + + + @@ -118,7 +118,7 @@ export type ComposerRootProps = Omit, "onSubmit" | "ref"> onSubmit?: (data: ComposerSubmitData) => void | Promise; isSubmitting?: boolean; commands?: ComposerCommandsMap; - requests?: ComposerRequest[]; // present → drives the request flow + questions?: ComposerQuestion[]; // present → drives the question flow defaultValue?: ComposerSnapshot; // uncontrolled editor doc value?: ComposerSnapshot; // controlled editor doc onValueChange?: (snapshot: ComposerSnapshot) => void; @@ -136,13 +136,13 @@ type ComposerMessageSubmit = { files: FileUIPart[]; }; -type ComposerRequestsSubmit = { - kind: "requests"; - requests: ComposerRequestEntry[]; // emitted by the request flow +type ComposerAnswersSubmit = { + kind: "answers"; + answers: ComposerAnswer[]; // emitted by the question flow }; ``` -Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.sendMessage`, a `"requests"` submit goes to `addToolOutput` (resolving the open `askUser` tool call). +Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.sendMessage`, an `"answers"` submit goes to `addToolOutput` (resolving the open `askUser` tool call). **`ComposerSnapshot`** is an opaque, branded wrapper around the editor's paragraph JSON (`{ __doc, __brand }`) used by the controlled `value` / `defaultValue` API — distinct from `Composer.Textarea`'s plain-string `value`. Treat it as a token: persist it and hand it back, but don't read into `__doc`. @@ -151,14 +151,14 @@ Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.se A selector hook over a module-singleton store (`useSyncExternalStore`); no context needed. Slices are identity-stable — a slice's reference changes only when its data does. ```ts -const { textarea, attachments, requests, panel, commands, isSubmitting } = useComposer(); +const { textarea, attachments, questions, panel, commands, isSubmitting } = useComposer(); ``` | Slice | Shape | | ------------- | --------------------------------------------------------------------------- | | `textarea` | The editor controller (`focus/blur/clear/insertText/insertChip/getText/setText/serialize/ensureFocus`) plus reactive `hasContent` | | `attachments` | `{ items, add, remove, openFileDialog, error, isDragging, fileInputRef, … }` | -| `requests` | `{ items, step, drafts, toggleOption, continueStep, dismissStep, isLastStep, isSingle, goBack, goNext, … }` | +| `questions` | `{ items, step, drafts, toggleOption, continueStep, dismissStep, isLastStep, isSingle, goBack, goNext, … }` | | `panel` | `{ isOpen, value }` — which panel item is open | | `commands` | `{ isOpen, trigger, query }` — prefix-popover state | | `isSubmitting`| Boolean mirror of the root's `isSubmitting` prop | @@ -186,22 +186,22 @@ The same controller surface is also exported as the module singleton **`composer … … - + ``` The matched item animates in (spring height via `useMeasure`, blur-in); only one is visible at a time. -### Requests — `Composer.Ask` / `AskHints` / `AskDismiss` / `AskContinue` +### Questions — `Composer.Question` / `Composer.QuestionHints` / `Composer.QuestionDismiss` / `Composer.QuestionContinue` -Active when the root receives a non-empty `requests` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `requests` store slice. +Active when the root receives a non-empty `questions` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `questions` store slice. -- `Composer.Ask` — renders the current question with its options and a free-text fallback; handles single- and multi-select, plus prev/next navigation across questions. -- `Composer.AskHints` — keyboard-hint pills (↑↓ navigate, ↵ select, ←→ between questions, esc skip). -- `Composer.AskDismiss` — skips the current question (`requests.dismissStep`). -- `Composer.AskContinue` — submits the form; labeled `"Continue"`, or `"Submit"` on the last step. The form handler routes it through `requests.continueStep`, which compiles per-request entries (flat `ComposerRequestEntry`) and fires `onSubmit({ kind: "requests", requests })`. +- `Composer.Question` — renders the current question with its options and a free-text fallback; handles single- and multi-select, plus prev/next navigation across questions. +- `Composer.QuestionHints` — keyboard-hint pills (↑↓ navigate, ↵ select, ←→ between questions, esc skip). +- `Composer.QuestionDismiss` — skips the current question (`questions.dismissStep`). +- `Composer.QuestionContinue` — submits the form; labeled `"Continue"`, or `"Submit"` on the last step. The form handler routes it through `questions.continueStep`, which compiles one answer per question (flat `ComposerAnswer`) and fires `onSubmit({ kind: "answers", answers })`. -When in request mode, swap the `Actions` row from the standard layout to ` `. +When in question mode, swap the `Actions` row from the standard layout to ` `. ### Commands — prefix-triggered popovers @@ -269,7 +269,7 @@ The `variant`/`icon` ride in the query string, so the token carries everything n ### Submission flow 1. `Composer.Submit` (or Enter in the editor) triggers form submit. -2. If `requests` is active and the user is mid-flow → `requests.continueStep()` advances or finalises (emitting `{ kind: "requests" }`). +2. If `questions` is active and the user is mid-flow → `questions.continueStep()` advances or finalises (emitting `{ kind: "answers" }`). 3. Otherwise → `serializeEditorContent` produces `{ text }` (chips already inlined), attachments become `FileUIPart[]`, the editor and attachments reset, and the root calls `onSubmit({ kind: "message", text, files })`. No `chips` or `tools` field — chips live in `text`, tool toggles live in the consumer. 4. The parent maps that to `chat.sendMessage({ parts: [...files, { type: "text", text }] }, { body: { webSearch, thinking } })`. @@ -597,7 +597,7 @@ const ChatSurface = ({ chatId }: { chatId: string }) => { {/* CommandLists */} {/* StepQueue */} - + @@ -608,9 +608,9 @@ const ChatSurface = ({ chatId }: { chatId: string }) => { {panelState.type === "ask-user" ? ( <> - - - + + + ) : ( diff --git a/COMPOSER.md b/COMPOSER.md index b45624ee..50d7ffb5 100644 --- a/COMPOSER.md +++ b/COMPOSER.md @@ -1,6 +1,6 @@ # Composer -The chat composer — a form-shaped, headless-ish input surface that handles text, attachments, slash commands, mention chips, and requests. It's a single component (`components/ai/composer.tsx`) exposed as a compound API via `Composer.X` parts. +The chat composer — a form-shaped, headless-ish input surface that handles text, attachments, slash commands, mention chips, and questions. It's a single component (`components/ai/composer.tsx`) exposed as a compound API via `Composer.X` parts. Import from `@/components/ai/composer`. @@ -43,11 +43,11 @@ type ComposerSubmitData = chips: ChipData[]; // inline mention chips } | { - kind: "requests"; - requests: ComposerRequestEntry[]; // produced by the request flow + kind: "answers"; + answers: ComposerAnswer[]; // produced by the question flow }; -type ComposerRequestEntry = +type ComposerAnswer = | { question: string; option: string } // single-select, picked an option | { question: string; text: string } // single-select, typed free text | { question: string; options: string[]; text: string } // multi-select (either field may be empty) @@ -67,7 +67,7 @@ If the editor is empty but attachments exist, `text` is set to `"Sent with attac | `onSubmit` | `(data: ComposerSubmitData) => void \| Promise` | Submit handler. | | `isSubmitting` | `boolean` | Disables `Composer.Submit` while truthy. Default `false`. | | `commands` | `ComposerCommandsMap` | Prefix → command-list config. See [Commands & chips](#commands--chips). | -| `requests` | `ComposerRequest[]` | When present, the composer enters request mode. See [Requests](#add-on-requests). | +| `questions` | `ComposerQuestion[]` | When present, the composer enters question mode. See [Questions](#add-on-questions). | | `defaultValue` | `ComposerSnapshot` | Initial uncontrolled editor content. | | `value` | `ComposerSnapshot` | Controlled editor content. | | `onValueChange` | `(snapshot) => void` | Fires on editor change. | @@ -142,7 +142,7 @@ Button that opens the native file picker. Accepts `IconButton` props. ### Add-on: panel -The panel is the area above the editor that shows command lists, active tool progress, or a request prompt. It auto-switches to `"command-list"` whenever a command list is open, regardless of the `value` you pass. +The panel is the area above the editor that shows command lists, active tool progress, or a question prompt. It auto-switches to `"command-list"` whenever a command list is open, regardless of the `value` you pass. #### `Composer.Panel` / `Composer.PanelItem` @@ -154,8 +154,8 @@ The panel is the area above the editor that shows command lists, active tool pro - - + + ``` @@ -261,15 +261,15 @@ When the filter matches nothing, the empty row acts as the single highlighted op The default `` renderer already wires this up. -### Add-on: requests +### Add-on: questions -Pass `requests` to switch the composer into request mode — clarifying questions, tool approvals, any choice put to the user. The default renderer covers the flow: +Pass `questions` to switch the composer into question mode — clarifying questions, tool approvals, any choice put to the user. The default renderer covers the flow: ```tsx - - - - + + + + @@ -277,23 +277,23 @@ Pass `requests` to switch the composer into request mode — clarifying question - - - + + + ``` Parts: -- `Composer.Ask` — full default UI (question text, options, step counter, nav arrows). -- `Composer.AskHints` — keyboard-hint footer (↑↓ ↵ ← → Esc). -- `Composer.AskDismiss` — skip the current question (`Esc`). -- `Composer.AskContinue` — advance / submit (`Enter`). Auto-toggles label between "Continue" and "Submit". +- `Composer.Question` — full default UI (question text, options, step counter, nav arrows). +- `Composer.QuestionHints` — keyboard-hint footer (↑↓ ↵ ← → Esc). +- `Composer.QuestionDismiss` — skip the current question (`Esc`). +- `Composer.QuestionContinue` — advance / submit (`Enter`). Auto-toggles label between "Continue" and "Submit". -Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between requests, **Esc** dismiss, printable keys type free-text. +Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between questions, **Esc** dismiss, printable keys type free-text. -On completion, `onSubmit` fires with `{ kind: "requests", requests }` where `requests` is a `ComposerRequestEntry[]` (one entry per request, in order). See [Submit data](#submit-data) for the entry shape. +On completion, `onSubmit` fires with `{ kind: "answers", answers }` where `answers` is a `ComposerAnswer[]` (one answer per question, in order). See [Submit data](#submit-data) for the entry shape. ## Imperative API & state @@ -302,7 +302,7 @@ There is no root `ref` handle. Editor content is controlled declaratively via `v For live state, read from the store with `useComposer(selector)`. It's a module singleton — no provider — so anything in the subtree, a toolbar, or a sibling panel can subscribe. A selector re-renders only when that slice changes identity: ```tsx -const requests = useComposer((composer) => composer.requests); // request machine + actions +const questions = useComposer((composer) => composer.questions); // question machine + actions const attachments = useComposer((composer) => composer.attachments); // add, remove, openFileDialog, items, error const commands = useComposer((composer) => composer.commands); // open command-list state (isOpen, trigger, query) ``` @@ -325,7 +325,7 @@ const items: CommandItemData[] = [ Exported from `@/components/ai/composer`: -`ComposerEditorHandle`, `ComposerSnapshot`, `ComposerSubmitData`, `ComposerMessageSubmit`, `ComposerRequestsSubmit`, `ComposerRequestEntry`, `ComposerCommandsMap`, `ComposerCommandsConfig`, `ComposerCommandsItems`, `CommandItemData`, `CommandItemKind`, `PrefixOnSelectContext`, `TriggerRule`, `ChipData`, `AttachmentsApi`. +`ComposerEditorHandle`, `ComposerSnapshot`, `ComposerSubmitData`, `ComposerMessageSubmit`, `ComposerAnswersSubmit`, `ComposerAnswer`, `ComposerCommandsMap`, `ComposerCommandsConfig`, `ComposerCommandsItems`, `CommandItemData`, `CommandItemKind`, `PrefixOnSelectContext`, `TriggerRule`, `ChipData`, `AttachmentsApi`. `ChipVariant` is re-used from `@/components/ai/chip`. diff --git a/components/ai/ask.tsx b/components/ai/ask.tsx deleted file mode 100644 index d232946f..00000000 --- a/components/ai/ask.tsx +++ /dev/null @@ -1,233 +0,0 @@ -"use client"; - -import { - type AskOptionsHandle, - Ask as AskPrimitive, - useAskOption, - useAskOptions, -} from "@intentface/ui/ask"; -import { Check, ChevronLeft, ChevronRight } from "@keyline-icons/react"; -import type { ComponentProps, RefObject } from "react"; -import { cn } from "@/lib/utils"; - -export type { AskOptionsHandle }; - -/** Ask root container. Stateless — consumers manage all state externally. */ -type AskRootProps = ComponentProps; - -const AskRoot = ({ className, ...props }: AskRootProps) => ( - -); - -/** Request label text. */ -type AskLabelProps = ComponentProps; - -const AskLabel = ({ className, ...props }: AskLabelProps) => ( - -); - -/** Row container for `Label` and optional `Navigation`. */ -type AskHeaderProps = ComponentProps; - -const AskHeader = ({ className, ...props }: AskHeaderProps) => ( - -); - -/** Row container for `Previous`, `StepLabel`, and `Next`. */ -type AskNavigationProps = ComponentProps; - -const AskNavigation = ({ className, ...props }: AskNavigationProps) => ( - -); - -const navigationButtonClasses = - "flex size-6 cursor-pointer items-center justify-center rounded-full text-ink-secondary transition-colors hover:bg-ink-primary/5 hover:text-ink-primary focus-visible:outline-2 focus-visible:outline-accent-bg/60 disabled:pointer-events-none disabled:opacity-30"; - -/** Navigate to the previous step. */ -type AskPreviousProps = ComponentProps; - -const AskPrevious = ({ className, ...props }: AskPreviousProps) => ( - - - -); - -/** Navigate to the next step. */ -type AskNextProps = ComponentProps; - -const AskNext = ({ className, ...props }: AskNextProps) => ( - - - -); - -/** Displays "{current} of {total}" step indicator. Supports custom children to override the default text. */ -type AskStepLabelProps = ComponentProps; - -const AskStepLabel = ({ className, ...props }: AskStepLabelProps) => ( - -); - -/** Fieldset wrapper for `Option` items. The primitive owns the radiogroup/group - * semantics and roving focus; selection flows through Option selected/onSelect. */ -type AskOptionsProps = ComponentProps & { - ref?: RefObject; -}; - -const AskOptions = ({ multiSelect = false, className, ...props }: AskOptionsProps) => ( - -); - -/** Selectable card. Self-registers with parent `Options` on mount (cmdk pattern). */ -type AskOptionProps = ComponentProps; - -const AskOption = ({ className, ...props }: AskOptionProps) => ( - -); - -/** Decorative selection indicator. The Option element itself carries the - * radio/checkbox role and checked state, so this is pure presentation — - * aria-hidden, no id, never focusable (a nested control would be invalid - * inside role=radio and would break the composer's key scoping). */ -const AskOptionInput = () => { - const options = useAskOptions(); - return options.multiSelect ? : ; -}; - -const INDICATOR_CLASS = cn( - "flex size-4 shrink-0 items-center justify-center rounded-[5px] bg-raised font-medium text-[10px] text-ink-secondary tabular-nums shadow-raised", -); - -const INDICATOR_SELECTED_CLASS = "bg-accent-raised text-white shadow-accent"; - -const AskOptionCheckIndicator = () => { - const option = useAskOption(); - return ( - - ); -}; - -/** Numbered indicator for single-select options: the item's 1-based index. */ -const AskOptionIndexIndicator = () => { - const option = useAskOption(); - const { items } = useAskOptions(); - const index = items.current.indexOf(option.value) + 1; - return ( - - ); -}; - -/** Flex column wrapper for `OptionLabel` and `OptionDescription`. */ -type AskOptionContentProps = ComponentProps; - -const AskOptionContent = ({ className, ...props }: AskOptionContentProps) => ( - -); - -/** Option title text. */ -type AskOptionLabelProps = ComponentProps; - -const AskOptionLabel = ({ className, ...props }: AskOptionLabelProps) => ( - -); - -/** Option subtitle/description text. */ -type AskOptionDescriptionProps = ComponentProps; - -const AskOptionDescription = ({ className, ...props }: AskOptionDescriptionProps) => ( - -); - -/** Keyboard shortcut hints displayed below the ask options. */ -type AskHintsProps = ComponentProps; - -const AskHints = ({ className, ...props }: AskHintsProps) => ( - -); - -export const Ask = Object.assign(AskRoot, { - Header: AskHeader, - Label: AskLabel, - Navigation: AskNavigation, - Previous: AskPrevious, - Next: AskNext, - StepLabel: AskStepLabel, - Options: AskOptions, - Option: AskOption, - OptionInput: AskOptionInput, - OptionContent: AskOptionContent, - OptionLabel: AskOptionLabel, - OptionDescription: AskOptionDescription, - Hints: AskHints, -}); diff --git a/components/ai/composer.tsx b/components/ai/composer.tsx index 95c9cb1d..0375f0dc 100644 --- a/components/ai/composer.tsx +++ b/components/ai/composer.tsx @@ -1,5 +1,5 @@ "use client"; -import { ArrowUp, Stop } from "@keyline-icons/react"; +import { ArrowUp, Check, ChevronLeft, ChevronRight, Stop } from "@keyline-icons/react"; // Styled composer — thin wrappers over @intentface/ui/composer. Behavior // (editor, store, machines, command plumbing) lives in the package; this file @@ -15,11 +15,12 @@ import { useComposerController, useComposerStore, useComposerSubmit, + useQuestionOption, + useQuestionOptions, } from "@intentface/ui/composer"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from "react"; -import { Ask } from "@/components/ai/ask"; import { Attachments } from "@/components/ai/attachments"; import { Chip } from "@/components/ai/chip"; import Button from "@/components/ui/button"; @@ -47,16 +48,16 @@ export type { AttachmentsApi, ChipData, CommandItemKind, + ComposerAnswer, + ComposerAnswersSubmit, ComposerCommandsConfig, ComposerCommandsItems, ComposerCommandsMap, ComposerEditorHandle, ComposerEditorState, ComposerMessageSubmit, - ComposerRequest, - ComposerRequestEntry, - ComposerRequestOption, - ComposerRequestsSubmit, + ComposerQuestion, + ComposerQuestionOption, ComposerSnapshot, ComposerSubmitData, ComposerSubmitOn, @@ -588,83 +589,169 @@ const ComposerCommands = ({ prefix }: ComposerCommandsProps) => ( ); // --------------------------------------------------------------------------- -// Ask (with sub-Parts) — default render for the request flow registered -// via the `requests` prop on Composer Root. +// Question — default render for the question flow armed by the `questions` +// prop on Composer Root. // --------------------------------------------------------------------------- -const ComposerAsk = () => { - const requests = useComposer((composer) => composer.requests); +const questionNavigationButtonClasses = + "flex size-6 cursor-pointer items-center justify-center rounded-full text-ink-secondary transition-colors hover:bg-ink-primary/5 hover:text-ink-primary focus-visible:outline-2 focus-visible:outline-accent-bg/60 disabled:pointer-events-none disabled:opacity-30"; + +// Decorative selection indicator: a check for multi-select, the option's +// 1-based index for single-select. The Option itself carries the radio or +// checkbox role and checked state, so this is aria-hidden and never focusable: +// a nested control is invalid inside role=radio and breaks the key scoping. +const QuestionOptionIndicator = () => { + const options = useQuestionOptions(); + const option = useQuestionOption(); + const index = options.items.current.indexOf(option.value) + 1; + return ( + + ); +}; - // Content only. The consumer gates the enclosing Panel on `requests.active`; - // this renders the request compound, or null when there are none. `display` - // keeps the last request on screen through an exit animation. - const request = requests.items?.[requests.step] ?? null; - const lastRequestRef = useRef(request); - if (request) lastRequestRef.current = request; - const display = request ?? lastRequestRef.current; +const ComposerQuestion = () => { + const questions = useComposer((composer) => composer.questions); + + // Content only. The consumer gates the enclosing Panel on `questions.active`; + // this renders the current question, or null when there are none. `display` + // keeps the last question on screen through an exit animation. + const question = questions.items?.[questions.step] ?? null; + const lastQuestionRef = useRef(question); + if (question) lastQuestionRef.current = question; + const display = question ?? lastQuestionRef.current; if (!display) return null; - const entry = requests.drafts.get(requests.step) ?? { + const draft = questions.drafts.get(questions.step) ?? { selected: new Set(), freeText: "", }; - const totalRequests = requests.items?.length ?? 0; + const totalQuestions = questions.items?.length ?? 0; return ( - - - {display.label} - {!requests.isSingle && totalRequests > 1 && ( - - - - {({ current, total }) => `${current} of ${total} requests`} - - - + + + + {display.label} + + {!questions.isSingle && totalQuestions > 1 && ( + + + + + + {({ current, total }) => `${current} of ${total} questions`} + + + + + )} - + {display.options && ( - {display.options.map((option) => { // One identity for key, value, lookup and toggle — so a supplied - // `value` is what the submit entry carries. + // `value` is what the answer carries. const optionValue = option.value ?? option.label; return ( - requests.toggleOption(optionValue)} + selected={draft.selected.has(optionValue)} + onSelect={() => questions.toggleOption(optionValue)} + className={cn( + "flex cursor-pointer items-start gap-2 rounded-lg p-2 leading-tight transition-colors", + "data-highlighted:bg-ink-primary/5", + // Options carry real focus (roving tabindex), but focus always + // tracks the highlight — the highlight wash IS the focus + // indication, same as command items. No extra ring. + "outline-none", + )} > - - - {option.label} + + + + {option.label} + {option.description && ( - {option.description} + + {option.description} + )} - - + + ); })} - + )} - + ); }; -const ComposerAskHints = ({ className, ...props }: ComponentProps) => { - const requests = useComposer((composer) => composer.requests); - const totalRequests = requests.items?.length ?? 0; +const ComposerQuestionHints = ({ + className, + ...props +}: ComponentProps) => { + const questions = useComposer((composer) => composer.questions); + const totalQuestions = questions.items?.length ?? 0; return ( - + ↑ ↓ navigate @@ -672,7 +759,7 @@ const ComposerAskHints = ({ className, ...props }: ComponentProps ↵ select - {!requests.isSingle && totalRequests > 1 && ( + {!questions.isSingle && totalQuestions > 1 && ( ← → between questions @@ -681,21 +768,21 @@ const ComposerAskHints = ({ className, ...props }: ComponentProps esc skip - + ); }; -type ComposerAskDismissProps = ComponentProps; +type ComposerQuestionDismissProps = ComponentProps; -const ComposerAskDismiss = ({ className, ...props }: ComposerAskDismissProps) => { - const requests = useComposer((composer) => composer.requests); +const ComposerQuestionDismiss = ({ className, ...props }: ComposerQuestionDismissProps) => { + const questions = useComposer((composer) => composer.questions); return ( ); }; @@ -736,10 +823,10 @@ export const Composer = Object.assign(ComposerRoot, { Panel: ComposerPanel, Popover: ComposerPopover, Textarea: ComposerTextarea, - Ask: ComposerAsk, - AskHints: ComposerAskHints, - AskDismiss: ComposerAskDismiss, - AskContinue: ComposerAskContinue, + Question: ComposerQuestion, + QuestionHints: ComposerQuestionHints, + QuestionDismiss: ComposerQuestionDismiss, + QuestionContinue: ComposerQuestionContinue, Commands: ComposerCommands, Command: ComposerCommand, CommandList: ComposerCommandList, diff --git a/components/chat.tsx b/components/chat.tsx index 36a8fc8b..5edc945c 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -41,7 +41,7 @@ import { type CommandItemData, Composer, type ComposerCommandsMap, - type ComposerRequest, + type ComposerQuestion, type ComposerSubmitData, } from "@/components/ai/composer"; import { GeneratedUi } from "@/components/ai/generated-ui"; @@ -63,9 +63,9 @@ import { getSourcesInfo, splitReasoningByHeaders, } from "@/lib/ai/message-info"; -import { getAskUserInfo, getAskUserStepInfo, getToolCallInfo } from "@/lib/ai/steps-info"; +import { getQuestionsInfo, getQuestionsStepInfo, getToolCallInfo } from "@/lib/ai/steps-info"; import { DEFAULT_TOOL_LABELS } from "@/lib/ai/tool-labels"; -import type { AppPart, AppUIMessage, AskUserQuestion, StepStatus } from "@/lib/ai/types"; +import type { AppPart, AppUIMessage, Question, StepStatus } from "@/lib/ai/types"; import { applyStopToMessages } from "@/lib/chat-instance"; import { fetchPlaygroundIssues, GroupedIssueCommands } from "@/lib/playground-demo"; import { useChatStore } from "@/lib/store/chat"; @@ -223,8 +223,8 @@ const TimelineToolCall = ({ part }: { part: ToolPart }) => { ); }; -const TimelineAskUser = ({ part }: { part: ToolPart }) => { - const { label, status, questions, answers, isComplete } = getAskUserStepInfo(part); +const TimelineQuestions = ({ part }: { part: ToolPart }) => { + const { label, status, questions, answers, isComplete } = getQuestionsStepInfo(part); return ( @@ -251,7 +251,7 @@ const InterleavedSteps = ({ }: { segments: MessageSegment[]; isStreaming: boolean; - // An open ask-user prompt means the turn is paused, not finished — keep the block + // Open questions mean the turn is paused, not finished — keep the block // in its active state (cycling header, open) rather than the "done" summary. awaitingInput?: boolean; }) => { @@ -392,7 +392,7 @@ const InterleavedSteps = ({ if (seg.type === "tool") { return seg.parts.map((part, j) => part.type === "tool-askUser" ? ( - + ) : ( ), @@ -433,12 +433,12 @@ const ChatMessageItem = memo( const fileParts = getFileParts(segments); const chain = getChainInfo(segments); const reasoning = chain.onlyReasoning ? getReasoningInfo(segments, isStreaming) : null; - const askUser = getAskUserInfo(parts); + const questionsInfo = getQuestionsInfo(parts); const sourcesInfo = isAssistant ? getSourcesInfo(parts) : null; // Every assistant turn keeps a status block, so it never appears-then-vanishes. A // finished reasoning-only turn uses the dedicated Reasoning disclosure; every other - // assistant turn — streaming, tools, ask-user, or a plain text reply — uses + // assistant turn — streaming, tools, questions, or a plain text reply — uses // InterleavedSteps, which shows a "Thought for Xs" header even with an empty timeline. const isReasoningOnlyDone = isAssistant && !isStreaming && !chain.hasTools && !!reasoning && reasoning.parts.length > 0; @@ -475,7 +475,7 @@ const ChatMessageItem = memo( )} @@ -523,7 +523,7 @@ const ChatMessageItem = memo( {isAssistant && message.metadata?.stopped && } {/* Actions — hide while waiting for tool input */} - {showActions && !askUser.isAwaitingInput && ( + {showActions && !questionsInfo.isAwaitingInput && ( {isAssistant && ( { +): QuestionsPanelState | null => { const lastAssistant = status === "ready" || status === "streaming" ? messages.findLast((message) => message.role === "assistant") @@ -684,7 +684,7 @@ const useAskUserPanelState = ( const next = part ? { - type: "ask-user" as const, + type: "questions" as const, toolCallId: part.toolCallId, questions: part.input.questions, } @@ -702,13 +702,13 @@ const useAskUserPanelState = ( }; // Thin bridge — the only composer piece that re-renders per stream chunk. It derives -// the ask-user panel state (referentially stable while nothing transitioned) so the +// the questions panel state (referentially stable while nothing transitioned) so the // memoized inner composer bails unless the panel actually changed. Idle otherwise — // tool/step status now renders in the assistant message. const ChatInput = () => { const { messages, status } = useChatMessages(); - const askUserPanelState = useAskUserPanelState(messages, status); - return ; + const questionsPanelState = useQuestionsPanelState(messages, status); + return ; }; type ChatInputInnerProps = { @@ -745,7 +745,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { // Playground knobs: which command prefixes mount, ghost suggestions, the // command surface, the submit chord (persisted config) — plus the ephemeral - // demo state (injected ask-user questions, the workspace-files context strip). + // demo state (injected demo questions, the workspace-files context strip). const commandToggles = useSettingsStore((state) => state.commands); const suggestions = useSettingsStore((state) => state.suggestions); const commandSurface = useSettingsStore((state) => state.commandSurface); @@ -829,18 +829,18 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { return map; }, [commandToggles, suggestions, commandItems]); - const isAskUser = panelState.type === "ask-user" || demoQuestions != null; - const askUserQuestions = panelState.type === "ask-user" ? panelState.questions : null; + const hasQuestions = panelState.type === "questions" || demoQuestions != null; + const toolQuestions = panelState.type === "questions" ? panelState.questions : null; // Boundary map: tool questions carry no identity, so positional ids are - // minted here. Memoized on the merged feed — setRequests resets the machine + // minted here. Memoized on the merged feed — setQuestions resets the machine // on any array identity change, so a fresh .map() per render would wipe // in-progress drafts. - const activeQuestions = demoQuestions ?? askUserQuestions; - const composerRequests = useMemo( + const activeQuestions = demoQuestions ?? toolQuestions; + const composerQuestions = useMemo( () => activeQuestions?.map( - (question, index): ComposerRequest => ({ + (question, index): ComposerQuestion => ({ id: `q-${index}`, label: question.question, options: question.options, @@ -852,21 +852,21 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { const handleSubmit = useCallback( async (data: ComposerSubmitData) => { - if (data.kind === "requests") { + if (data.kind === "answers") { // Demo questions from the playground card: the flow completing IS the // demo — discard the entries and clear the injection. if (demoQuestions) { setDemoQuestions(null); return; } - if (panelState.type !== "ask-user") return; + if (panelState.type !== "questions") return; // Project the flat entries back into the question→answer map the tool - // output is read as (getAskUserInfo / getAskUserStepInfo). Ids resolve - // through the minted requests; skipped entries stay absent. + // output is read as (getQuestionsInfo / getQuestionsStepInfo). Ids resolve + // through the minted questions; skipped entries stay absent. const answers: Record = {}; - for (const entry of data.requests) { + for (const entry of data.answers) { if (entry.selected.length === 0 && entry.text === undefined) continue; - const label = composerRequests?.find((request) => request.id === entry.id)?.label; + const label = composerQuestions?.find((question) => question.id === entry.id)?.label; if (!label) continue; answers[label] = [...entry.selected, entry.text].filter(Boolean).join(", "); } @@ -929,7 +929,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { sendMessage, addToolOutput, panelState, - composerRequests, + composerQuestions, toolValues, selections, clearSelections, @@ -943,15 +943,15 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { onSubmit={handleSubmit} isSubmitting={isSending} commands={composerCommands} - requests={composerRequests} + questions={composerQuestions} > {(composer) => { // One at a time, by priority: an active command list wins, else the - // composer's own request flow. (Tool/step status now lives in the + // composer's own question flow. (Tool/step status now lives in the // assistant message, not the composer.) With the popover surface - // selected, the command list lifts out of the panel — ask-user - // always stays in-flow. + // selected, the command list lifts out of the panel — questions + // always stay in-flow. if (composer.commands.active && commandSurface === "panel") { return ( <> @@ -961,7 +961,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { ); } - if (composer.requests.active) return ; + if (composer.questions.active) return ; return null; }} @@ -1008,7 +1008,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { { } /> - {isAskUser ? ( + {hasQuestions ? ( - - + + ) : ( diff --git a/content/docs/handbook/accessibility.mdx b/content/docs/handbook/accessibility.mdx index 4cb8a680..6a734ac5 100644 --- a/content/docs/handbook/accessibility.mdx +++ b/content/docs/handbook/accessibility.mdx @@ -10,7 +10,7 @@ optional. ## What you have to supply - **Focus indication.** Focus moves correctly; nothing paints it. Style - `:focus-visible` yourself, and remember that in command lists and request + `:focus-visible` yourself, and remember that in command lists and question options the highlight *is* the indicator, so a faint highlight is a missing focus ring rather than a soft hover. - **Contrast.** No colour ships with the package. @@ -64,6 +64,6 @@ rather than failing quietly. Both are argued on the page that makes the choice: ## Testing The package runs an axe-based suite over the composer combobox wiring, the -request flows, the display primitives' status attributes, and accessible names +question flows, the display primitives' status attributes, and accessible names across controls. Automated checks cannot evaluate contrast or focus visibility in your styling, so those need your own pass. diff --git a/content/docs/handbook/styling.mdx b/content/docs/handbook/styling.mdx index 1357a215..4bb0db95 100644 --- a/content/docs/handbook/styling.mdx +++ b/content/docs/handbook/styling.mdx @@ -9,7 +9,7 @@ ways. ## Data attributes Each part stamps an attribute naming itself (`data-composer-editor`, -`data-ask-option`) plus attributes for the state it tracks. This is the +`data-composer-question-option`) plus attributes for the state it tracks. This is the default way to style state, since CSS selects on it without anything running in JavaScript. @@ -42,7 +42,7 @@ from a boolean, a `data-*` variant does the same job in CSS. Sixteen parts declare state; the rest receive an empty object: `Composer.Root`, `Composer.Textarea`, `Composer.Submit`, `Composer.Panel`, `Composer.Popover`, `Composer.Command`, `Composer.ContextWindow`, `Message.Root`, `Chip.Root`, -`Thread.Overlay`, `Ask.Option`, `Attachments.Dropzone`, and the collapsible +`Thread.Overlay`, `Composer.QuestionOption`, `Attachments.Dropzone`, and the collapsible parts behind `Steps` and `Reasoning`. ## The render prop diff --git a/content/docs/index.mdx b/content/docs/index.mdx index 16636621..26e6c037 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -44,14 +44,13 @@ with no adapter. | Entry point | What it covers | | --- | --- | -| `/composer` | Contenteditable editor, inline chips, prefix command lists, attachments, request flow | +| `/composer` | Contenteditable editor, inline chips, prefix command lists, attachments, questions for the user | | `/thread` | Scroll container: at-bottom detection, auto-follow, dock measurement, prepend restoration | | `/message` | Message parts, turn grouping, chip-segmented text | | `/steps` | Collapsible timeline of a run: reasoning, tool calls and answered questions | | `/reasoning` | Reasoning disclosure that tracks streaming state and elapsed time | | `/chip` | Inline tokens for composer mentions and chips in messages | | `/attachments` | Attachment tray: items, remove, drop zone and file picker | -| `/ask` | Parts for the requests a model makes of the user: questions and tool approvals | | `/shell` | Collapsible, resizable sidebar beside the viewport | | `/nav` | Nav tree with roving focus, typeahead and collapsible groups | | `/tabs` | Closable tabs whose panel renders in the layout or in a popover | diff --git a/content/docs/primitives/composer.mdx b/content/docs/primitives/composer.mdx index b70af16c..9168ed51 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -1,6 +1,6 @@ --- title: Composer -description: Rich-text chat input with chips, slash/mention commands, attachments, and a request flow (questions and tool approvals). +description: Rich-text chat input with chips, slash/mention commands, attachments, and questions for the user, from clarifying questions to tool approvals. source: composer --- @@ -8,7 +8,7 @@ import { Basic } from './composer/demos/basic'; import { Commands } from './composer/demos/commands'; import { Popover } from './composer/demos/popover'; import { ApprovalFlow } from './composer/demos/approval-flow'; -import { AskFlow } from './composer/demos/ask-flow'; +import { QuestionFlow } from './composer/demos/question-flow'; import { AttachmentsDemo } from './composer/demos/attachments'; import { Controlled } from './composer/demos/controlled'; import { Store } from './composer/demos/store'; @@ -19,7 +19,7 @@ import { Store } from './composer/demos/store'; - **Chat input** — a hand-rolled contenteditable over a flat segment model: native typing and IME, inline chips, attachments. - **Prefix commands** — type `/`, `@`, or other prefixes to open command lists. -- **Panel** — hosts command results, live steps, or a request prompt above the field. +- **Panel** — hosts command results, live steps, or a question prompt above the field. - **Headless** — behavior lives in `@intentface/ui`; the demos below show one way to style it, and every class in them is yours to change. - **Isolate it from your stream** — see [Why the composer needs isolating](#why-the-composer-needs-isolating). The editor is the most expensive thing on the page to re-render per token. - **Get started** — see [Quick start](/quick-start) to add the package. @@ -60,7 +60,7 @@ The bare nesting — every part is optional except `Composer` and `Container`: `Composer.Panel` takes plain children or a callback receiving the composer state, and shows only while its resolved content is non-empty. Gate each part on -the state it belongs to (`commands.active`, `requests.active`, …) and the panel +the state it belongs to (`commands.active`, `questions.active`, …) and the panel opens and closes to match — priority is the order of your branches. ## Examples @@ -83,22 +83,22 @@ or the other; the content is identical. } file="primitives/composer/demos/popover.tsx" /> -### Request flow +### Question flow -Setting the `requests` prop — a clarifying question, a tool approval, any -choice you put to the user — arms the request flow and flips `requests.active`; -compose the `Ask` parts from `@intentface/ui/ask` inside a `Panel` (or -`Popover`), reading the current step from `useComposer(c => c.requests)`. The +Setting the `questions` prop — a clarifying question, a tool approval, any +choice you put to the user — arms the question flow and flips `questions.active`; +compose the `Composer.Question` parts inside a `Panel` (or +`Popover`), reading the current step from `useComposer(c => c.questions)`. The flow steps through each question (single- or multi-select), and answering or -skipping the last one fires `onSubmit` with `{ kind: "requests" }` — one flat -entry per request, keyed by the id you minted, carrying option values. Passing -a fresh `requests` array re-arms it from the first step. +skipping the last one fires `onSubmit` with `{ kind: "answers" }` — one flat +answer per question, keyed by the id you minted, carrying option values. Passing +a fresh `questions` array re-arms it from the first step. -} file="primitives/composer/demos/ask-flow.tsx" /> +} file="primitives/composer/demos/question-flow.tsx" /> A tool approval is the same mechanism, not a feature: mint the tool call id as -the request id, put your decision vocabulary in the option `value`s, and route -the entry back to the tool in `onSubmit`. A single request mounts no +the question id, put your decision vocabulary in the option `value`s, and route +the answer back to the tool in `onSubmit`. A single question mounts no step-navigation parts. } file="primitives/composer/demos/approval-flow.tsx" /> @@ -156,7 +156,7 @@ const ChatInputInner = memo(({ panel, status }: ChatInputInnerProps) => ( )); ``` -`derivePanelState` is yours — what belongs in the panel (a request prompt, a +`derivePanelState` is yours — what belongs in the panel (a question prompt, a status line) is your product's policy. Return a referentially stable value while nothing has changed, so the inner composer bails on every chunk except real panel transitions. @@ -165,7 +165,7 @@ transitions. `Composer.Root` is a ``, so submission and key handling follow form semantics. The editor interprets keys by mode — a command list being open, or -the request flow being active, takes priority over normal typing. +the question flow being active, takes priority over normal typing. **Normal typing** @@ -188,7 +188,7 @@ export const commandKeys = [ -**Request flow active** +**Question flow active** export const requestKeys = [ { keys: "ArrowUp / ArrowDown", description: "Navigate options; up past the first refocuses the editor." }, @@ -224,9 +224,9 @@ The `` that owns submission, store resolution, drag-and-drop, and the prop → store bridges. Renders `data-composer-root`. export const rootProps = [ - { name: "onSubmit", type: "(data: ComposerSubmitData) => void | Promise", description: "Fires on message submit and on request entries. Discriminate on data.kind." }, + { name: "onSubmit", type: "(data: ComposerSubmitData) => void | Promise", description: "Fires on message submit and on answers. Discriminate on data.kind." }, { name: "commands", type: "ComposerCommandsMap", default: "{}", description: "Prefix → command config (kind, trigger, items, suggestion, placeholder). suggestion: false disables ghost-text completion; placeholder sets the per-prefix empty-query hint (never shown alongside a suggestion — the suggestion wins)." }, - { name: "requests", type: "ComposerRequest[]", description: "When set, arms the request flow (blurs the editor, flips requests.active)." }, + { name: "questions", type: "ComposerQuestion[]", description: "When set, arms the question flow (blurs the editor, flips questions.active)." }, { name: "isSubmitting", type: "boolean", default: "false", description: "Flips Submit to a stop affordance and gates re-submits." }, { name: "value", type: "ComposerSnapshot", description: "Controlled editor content." }, { name: "defaultValue", type: "ComposerSnapshot", description: "Uncontrolled initial editor content." }, @@ -281,7 +281,7 @@ export const Chat = () => { ```ts type ComposerSubmitData = | { kind: "message"; text: string; files: AttachmentItem[] } - | { kind: "requests"; requests: ComposerRequestEntry[] }; + | { kind: "answers"; answers: ComposerAnswer[] }; ``` `files` are the generic attachment descriptors — your `onSubmit` adapts them to @@ -410,7 +410,7 @@ part-specific props. A surface region above the field. `children` is either plain nodes or a callback `(composer) => ReactNode` receiving the composer state, so you pick what to show -by priority (`commands.active ? : requests.active ? : +by priority (`commands.active ? : questions.active ? : null`). By default (`anchor`) it renders as a **collision-aware, portaled overlay** anchored to the `Container` — flipping / shifting / sizing to stay on screen (via `@floating-ui/dom`); anchor a ref/element elsewhere, or pass `anchor={false}` for an @@ -562,18 +562,18 @@ still flows top-to-bottom — and calls the callback once per group with ``` -### Ask +### Composer.Question -The composer owns the request *state* — `items`, the current step, the -drafts — but renders none of the prompt UI. Compose that from the `Ask` -namespace in `@intentface/ui/ask`, reading the step through -`useComposer((c) => c.requests)` and gating the enclosing `Panel` or `Popover` -on `requests.active`. +The composer owns the question *state* — `items`, the current step, the +drafts — but renders none of the prompt UI. Compose that from the +`Composer.Question` parts, reading the step through +`useComposer((c) => c.questions)` and gating the enclosing `Panel` or `Popover` +on `questions.active`. -`Ask.Dismiss` is a plain button you wire to `requests.dismissStep`; -`Ask.Continue` is `type="submit"`, so the enclosing `Composer.Root` form +`Composer.QuestionDismiss` is a plain button you wire to `questions.dismissStep`; +`Composer.QuestionContinue` is `type="submit"`, so the enclosing `Composer.Root` form drives it. Neither ships copy — supply the labels as children, and read -`requests.isLastStep` to switch the continue wording. +`questions.isLastStep` to switch the continue wording. ### Hooks diff --git a/content/docs/primitives/composer/demos/approval-flow.tsx b/content/docs/primitives/composer/demos/approval-flow.tsx index ac247b58..95cba283 100644 --- a/content/docs/primitives/composer/demos/approval-flow.tsx +++ b/content/docs/primitives/composer/demos/approval-flow.tsx @@ -1,20 +1,19 @@ "use client"; -import { Ask } from "@intentface/ui/ask"; import { Composer, - type ComposerRequest, - type ComposerRequestEntry, - type ComposerRequestOption, + type ComposerAnswer, + type ComposerQuestion, + type ComposerQuestionOption, type ComposerSubmitData, useComposer, } from "@intentface/ui/composer"; import { type ComponentProps, useState } from "react"; -// A tool approval is just a request: the id is the tool call id, the option -// values are your decision vocabulary, and the entry routes back to the tool +// A tool approval is just a question: the id is the tool call id, the option +// values are your decision vocabulary, and the answer routes back to the tool // in onSubmit. The package never learns the word "approval" — and a single -// request simply doesn't mount the step-navigation parts. +// question simply doesn't mount the step-navigation parts. const COMMAND = "pnpm test"; const TOOL_CALL_ID = "call_run_tests"; @@ -28,9 +27,9 @@ const OPTIONS = [ command: COMMAND, }, { value: "no", label: "No" }, -] satisfies readonly (ComposerRequestOption & { command?: string })[]; +] satisfies readonly (ComposerQuestionOption & { command?: string })[]; -const REQUESTS: ComposerRequest[] = [ +const QUESTIONS: ComposerQuestion[] = [ { id: TOOL_CALL_ID, label: `Do you want to allow me to run ${COMMAND} for this workspace?`, @@ -45,13 +44,13 @@ const DECISION_COPY: Record = { }; export const ApprovalFlow = () => { - const [entry, setEntry] = useState(null); + const [entry, setEntry] = useState(null); const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind !== "requests") return; + if (data.kind !== "answers") return; // Route by the id we minted — this is where a real app resolves the call // (e.g. addToolOutput / executing or rejecting the tool). - const resolved = data.requests.find((request) => request.id === TOOL_CALL_ID); + const resolved = data.answers.find((answer) => answer.id === TOOL_CALL_ID); if (resolved) setEntry(resolved); }; @@ -66,7 +65,7 @@ export const ApprovalFlow = () => { // Reserve height and bottom-anchor so the panel opening never shifts the page.
@@ -107,32 +106,32 @@ export const ApprovalFlow = () => { ); }; -// Single request: no Navigation, no StepLabel — the label, the command being -// approved (free-form children inside Ask.Root), and numbered options. +// Single question: no Navigation, no StepLabel — the label, the command being +// approved (free-form children inside Composer.Question), and numbered options. const Prompt = () => { - const requests = useComposer((composer) => composer.requests); - const request = requests.items?.[requests.step]; + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; - if (!request) return null; + if (!question) return null; - const draft = requests.drafts.get(requests.step); + const draft = questions.drafts.get(questions.step); return ( - - - + + + Do you want to allow me to run{" "} {COMMAND} {" "} for this workspace? - - + +
{COMMAND}
- { const optionValue = option.value ?? option.label; const selected = Boolean(draft?.selected.has(optionValue)); return ( - requests.toggleOption(optionValue)} + onSelect={() => questions.toggleOption(optionValue)} className="group flex cursor-pointer items-start gap-2 rounded-[10px] p-2 outline-none transition-colors data-highlighted:bg-[#f4f4f4] dark:data-highlighted:bg-[#232323]" > { > {index + 1} - - + + {option.label} {"command" in option && ( <> @@ -169,8 +168,8 @@ const Prompt = () => { )} - - + + {/* Enter selects the highlighted row — surface that on it. */} - + ); })} - -
+ + ); }; @@ -204,19 +203,19 @@ const SendIcon = (props: ComponentProps<"svg">) => ( ); const Controls = () => { - const requests = useComposer((composer) => composer.requests); + const questions = useComposer((composer) => composer.questions); return ( <> - Skip - - + + Submit - + ); }; diff --git a/content/docs/primitives/composer/demos/ask.tsx b/content/docs/primitives/composer/demos/ask.tsx deleted file mode 100644 index b53766ae..00000000 --- a/content/docs/primitives/composer/demos/ask.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { Ask } from "@intentface/ui/ask"; -import { useComposer } from "@intentface/ui/composer"; - -// The parts are structural; the current request and selections come from the -// composer's requests slice. -export const Prompt = () => { - const requests = useComposer((composer) => composer.requests); - const request = requests.items?.[requests.step]; - - if (!request) return null; - - const draft = requests.drafts.get(requests.step); - const total = requests.items?.length ?? 0; - - return ( - - - - {request.label} - - {!requests.isSingle && total > 1 && ( - - - ‹ - - - {({ current, total: count }) => `${current} of ${count}`} - - - › - - - )} - - {request.options && ( - - {request.options.map((option) => { - // Options are identified by their value, falling back to the label. - const optionValue = option.value ?? option.label; - const selected = Boolean(draft?.selected.has(optionValue)); - return ( - requests.toggleOption(optionValue)} - className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 outline-none transition-colors data-highlighted:bg-zinc-950/5 dark:data-highlighted:bg-white/8" - > - {/* Decorative: the Option itself carries the radio/checkbox role. */} - - - - {option.label} - - {option.description && ( - - {option.description} - - )} - - - ); - })} - - )} - - ); -}; - -// Dismiss is a plain button you wire up; Continue is type=submit, so the -// enclosing Composer.Root form drives it. -export const Controls = () => { - const requests = useComposer((composer) => composer.requests); - - return ( - <> - - Skip - - - {requests.isLastStep ? "Done" : "Continue"} - - - ); -}; - -const STEP_BUTTON_CLASS = - "flex size-6 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-30 dark:hover:bg-white/8 dark:hover:text-zinc-100"; diff --git a/content/docs/primitives/composer/demos/ask-flow.tsx b/content/docs/primitives/composer/demos/question-flow.tsx similarity index 89% rename from content/docs/primitives/composer/demos/ask-flow.tsx rename to content/docs/primitives/composer/demos/question-flow.tsx index c9bc1c9d..962729ae 100644 --- a/content/docs/primitives/composer/demos/ask-flow.tsx +++ b/content/docs/primitives/composer/demos/question-flow.tsx @@ -1,14 +1,18 @@ "use client"; -import { Composer, type ComposerRequest, type ComposerSubmitData } from "@intentface/ui/composer"; +import { + Composer, + type ComposerQuestion, + type ComposerSubmitData, +} from "@intentface/ui/composer"; import { ArrowUp } from "@keyline-icons/react"; import { useState } from "react"; -import { Controls, Prompt } from "./ask"; +import { Controls, Prompt } from "./question"; -// Setting `requests` arms the flow and flips requests.active. Answering or -// skipping the last one fires onSubmit with { kind: "requests" } — entries +// Setting `questions` arms the flow and flips questions.active. Answering or +// skipping the last one fires onSubmit with { kind: "questions" } — entries // keyed by the ids minted here, carrying option values (label fallback). -const REQUESTS: ComposerRequest[] = [ +const QUESTIONS: ComposerQuestion[] = [ { id: "framework", label: "Which framework are you deploying to?", @@ -39,24 +43,24 @@ const REQUESTS: ComposerRequest[] = [ }, ]; -export const AskFlow = () => { - const [requests, setRequests] = useState(REQUESTS); +export const QuestionFlow = () => { + const [questions, setQuestions] = useState(QUESTIONS); const [done, setDone] = useState(false); const handleSubmit = (data: ComposerSubmitData) => { - if (data.kind === "requests") setDone(true); + if (data.kind === "answers") setDone(true); }; const reset = () => { setDone(false); - setRequests([...REQUESTS]); + setQuestions([...QUESTIONS]); }; return ( // Reserve height and bottom-anchor so the panel opening never shifts the page.
diff --git a/content/docs/primitives/composer/demos/question.tsx b/content/docs/primitives/composer/demos/question.tsx new file mode 100644 index 00000000..8ff7e579 --- /dev/null +++ b/content/docs/primitives/composer/demos/question.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Composer, useComposer } from "@intentface/ui/composer"; + +// The parts are structural; the current question and selections come from the +// composer's questions slice. +export const Prompt = () => { + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; + + if (!question) return null; + + const draft = questions.drafts.get(questions.step); + const total = questions.items?.length ?? 0; + + return ( + + + + {question.label} + + {!questions.isSingle && total > 1 && ( + + + ‹ + + + {({ current, total: count }) => `${current} of ${count}`} + + + › + + + )} + + {question.options && ( + + {question.options.map((option) => { + // Options are identified by their value, falling back to the label. + const optionValue = option.value ?? option.label; + const selected = Boolean(draft?.selected.has(optionValue)); + return ( + questions.toggleOption(optionValue)} + className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 outline-none transition-colors data-highlighted:bg-zinc-950/5 dark:data-highlighted:bg-white/8" + > + {/* Decorative: the Option itself carries the radio/checkbox role. */} + + + + {option.label} + + {option.description && ( + + {option.description} + + )} + + + ); + })} + + )} + + ); +}; + +// Dismiss is a plain button you wire up; Continue is type=submit, so the +// enclosing Composer.Root form drives it. +export const Controls = () => { + const questions = useComposer((composer) => composer.questions); + + return ( + <> + + Skip + + + {questions.isLastStep ? "Done" : "Continue"} + + + ); +}; + +const STEP_BUTTON_CLASS = + "flex size-6 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-30 dark:hover:bg-white/8 dark:hover:text-zinc-100"; diff --git a/lib/ai/steps-info.ts b/lib/ai/steps-info.ts index 800a4fb9..1485420f 100644 --- a/lib/ai/steps-info.ts +++ b/lib/ai/steps-info.ts @@ -4,34 +4,34 @@ import { isToolPart, type ToolPart, type UnknownPart } from "@intentface/ui/types"; import type { ToolLabels } from "@/lib/ai/tool-labels"; -import type { AskUserInput, AskUserQuestion, StepStatus } from "@/lib/ai/types"; +import type { Question, QuestionsInput, StepStatus } from "@/lib/ai/types"; // --------------------------------------------------------------------------- -// Ask-user extraction — this app's askUser tool: its part type name, input +// Question extraction — this app's askUser tool: its part type name, input // schema, and output JSON format. Moved here from the package, which now ships // only the generic ToolPart contract. // --------------------------------------------------------------------------- -/** A single answered ask-user exchange with parsed questions and answers. */ -export type AskUserAnswered = { +/** A single answered question exchange with parsed questions and answers. */ +export type AnsweredQuestions = { toolCallId: string; - questions: AskUserQuestion[]; + questions: Question[]; answers: Record; }; -/** Ask-user tool call info for a message. */ -export type AskUserInfo = { - /** True when at least one ask-user tool is waiting for user input. */ +/** Question tool call info for a message. */ +export type QuestionsInfo = { + /** True when at least one askUser tool call is waiting for user input. */ isAwaitingInput: boolean; - /** Answered ask-user exchanges with parsed Q&A pairs. */ - answered: AskUserAnswered[]; + /** Answered question exchanges with parsed Q&A pairs. */ + answered: AnsweredQuestions[]; }; /** * Extracts askUser tool parts and returns structured info: whether any are * awaiting input, and parsed Q&A pairs for answered ones. */ -export const getAskUserInfo = (allParts: readonly UnknownPart[]): AskUserInfo => { +export const getQuestionsInfo = (allParts: readonly UnknownPart[]): QuestionsInfo => { const parts = allParts.filter( (p): p is ToolPart => isToolPart(p) && @@ -39,10 +39,10 @@ export const getAskUserInfo = (allParts: readonly UnknownPart[]): AskUserInfo => (p.state === "input-available" || p.state === "output-available"), ); - const answered: AskUserAnswered[] = parts + const answered: AnsweredQuestions[] = parts .filter((p) => p.state === "output-available") .map((p) => { - const input = p.input as AskUserInput | undefined; + const input = p.input as QuestionsInput | undefined; let answers: Record = {}; try { answers = JSON.parse(p.output as string) as Record; @@ -125,18 +125,18 @@ export const getToolCallInfo = (part: ToolPart, labels: ToolLabels = {}): ToolCa return { label, status, summary, errorText, sources }; }; -export type AskUserStepInfo = { +export type QuestionsStepInfo = { label: string; status: StepStatus; - questions: AskUserQuestion[]; + questions: Question[]; answers: Record; isComplete: boolean; }; -export const getAskUserStepInfo = (part: ToolPart): AskUserStepInfo => { +export const getQuestionsStepInfo = (part: ToolPart): QuestionsStepInfo => { const isComplete = part.state === "output-available"; - const input = part.input as AskUserInput | undefined; - const questions: AskUserQuestion[] = input?.questions ?? []; + const input = part.input as QuestionsInput | undefined; + const questions: Question[] = input?.questions ?? []; let answers: Record = {}; if (isComplete) { diff --git a/lib/ai/types.ts b/lib/ai/types.ts index 40e9e61b..911aaeb6 100644 --- a/lib/ai/types.ts +++ b/lib/ai/types.ts @@ -33,10 +33,9 @@ export type AppPart = Extrac { type: Type } >; -// The askUser tool's input, from its zod schema; the composer's generic question -// contract is satisfied structurally (see the assignability lock below). -export type AskUserInput = sdk.InferToolInput; -export type AskUserQuestion = AskUserInput["questions"][number]; +// The askUser tool's input, from its zod schema. +export type QuestionsInput = sdk.InferToolInput; +export type Question = QuestionsInput["questions"][number]; // This app's concrete message roles. The headless Message primitive takes an // opaque role string; the app owns the set. @@ -64,7 +63,7 @@ export type FilePartContractCheck = Assert; -// No assignability lock for the composer's `requests` prop: ComposerRequest +// No assignability lock for the composer's `questions` prop: ComposerQuestion // requires a consumer-minted `id`, so tool questions are mapped explicitly at -// the boundary (useAskUserPanelState in components/chat.tsx) rather than +// the boundary (useQuestionsPanelState in components/chat.tsx) rather than // passed through structurally. diff --git a/lib/playground-demo.tsx b/lib/playground-demo.tsx index ea4509b5..cc766fae 100644 --- a/lib/playground-demo.tsx +++ b/lib/playground-demo.tsx @@ -1,12 +1,12 @@ import { type CommandItemData, Composer } from "@/components/ai/composer"; -import type { AskUserQuestion } from "@/lib/ai/types"; +import type { Question } from "@/lib/ai/types"; // Demo data for the playground cards (formerly app/playground/page.tsx): the -// ask-user question sets, the fake async issue list behind the `#` command, +// question sets, the fake async issue list behind the `#` command, // and the "open in the workspace" context strip. Pure fixtures — the toggles // that mount them live in the Composer card. -export const singleQuestion: AskUserQuestion[] = [ +export const singleQuestion: Question[] = [ { question: "Which database should we use?", header: "Database", @@ -27,7 +27,7 @@ export const singleQuestion: AskUserQuestion[] = [ }, ]; -export const multipleQuestions: AskUserQuestion[] = [ +export const multipleQuestions: Question[] = [ { question: "Which auth method do you prefer?", header: "Auth", diff --git a/lib/store/playground.ts b/lib/store/playground.ts index 0fda3780..63fb3f17 100644 --- a/lib/store/playground.ts +++ b/lib/store/playground.ts @@ -1,15 +1,15 @@ import { create } from "zustand"; -import type { AskUserQuestion } from "@/lib/ai/types"; +import type { Question } from "@/lib/ai/types"; // Ephemeral demo state driven by the playground cards. Deliberately NOT -// persisted (unlike the settings store): firing an ask-user question or +// persisted (unlike the settings store): firing a demo question or // showing the demo context strip is a transient demonstration, not // configuration — a reload resets it. type PlaygroundStore = { - /** Demo ask-user questions injected into the composer; null when idle. */ - demoQuestions: AskUserQuestion[] | null; - setDemoQuestions: (questions: AskUserQuestion[] | null) => void; + /** Demo questions injected into the composer; null when idle. */ + demoQuestions: Question[] | null; + setDemoQuestions: (questions: Question[] | null) => void; /** Show the demo "open files" context strip above the composer. */ showContextStrip: boolean; setShowContextStrip: (show: boolean) => void; diff --git a/next.config.ts b/next.config.ts index d05941be..9163e73c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,7 +13,6 @@ const packageSourceAliases = { "@intentface/ui/reasoning": "./packages/ui/src/reasoning/index.ts", "@intentface/ui/steps": "./packages/ui/src/steps/index.ts", "@intentface/ui/attachments": "./packages/ui/src/attachments/index.ts", - "@intentface/ui/ask": "./packages/ui/src/ask/index.ts", "@intentface/ui/message": "./packages/ui/src/message/index.ts", "@intentface/ui/message-utils": "./packages/ui/src/message-utils.ts", "@intentface/ui/chip-markdown": "./packages/ui/src/chip-markdown.ts", diff --git a/packages/ui/README.md b/packages/ui/README.md index e7866a51..be75b011 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -70,14 +70,14 @@ tabs are app-shell parts rather than chat parts, and are useful on their own: | Import | What it is | | --- | --- | -| `@intentface/ui/composer` | Rich-text input over a purpose-built contenteditable engine: `/` and `@` command palette, inline chips, attachments, request flow (questions and tool approvals). One store per ``, or bring your own via `Composer.createStore()`. | +| `@intentface/ui/composer` | Rich-text input over a purpose-built contenteditable engine: `/` and `@` command palette, inline chips, attachments, questions for the user (clarifying questions and tool approvals). One store per ``, or bring your own via `Composer.createStore()`. | | `@intentface/ui/thread` | Scroll container with at-bottom detection, auto-follow, and docked-composer measurement. | | `@intentface/ui/message` | Message parts, turns, chip-segmented text, selection hooks. | | `@intentface/ui/steps`, `/reasoning` | Tool-call timelines and reasoning disclosure. | | `@intentface/ui/shell` | A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot. | | `@intentface/ui/nav` | A nav tree with roving focus, typeahead, collapsible groups, and a guide ladder down the left edge. | | `@intentface/ui/tabs` | An open-ended, closable tab collection whose panel renders in the layout or anchored to its own tab. | -| `@intentface/ui/chip`, `/attachments`, `/ask` | The remaining building blocks. | +| `@intentface/ui/chip`, `/attachments` | The remaining building blocks. | | `@intentface/ui/generative` | Experimental. Typed helpers that check a model's UI tool call against a catalog, read it while it streams, resolve its data bindings, and write it as Markdown. | | `@intentface/ui/types` | The structural message contract + part type guards. | | `@intentface/ui/message-utils` | Part segmentation, turn grouping, reasoning/source derivation. | diff --git a/packages/ui/package.json b/packages/ui/package.json index bc12982e..a2f2d14f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -63,10 +63,6 @@ "types": "./dist/attachments/index.d.ts", "default": "./dist/attachments/index.js" }, - "./ask": { - "types": "./dist/ask/index.d.ts", - "default": "./dist/ask/index.js" - }, "./message": { "types": "./dist/message/index.d.ts", "default": "./dist/message/index.js" diff --git a/packages/ui/src/ask/index.parts.ts b/packages/ui/src/ask/index.parts.ts deleted file mode 100644 index 2195399a..00000000 --- a/packages/ui/src/ask/index.parts.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Part names for the `Ask` namespace. Deliberately carries no -// "use client" directive: this module and index.ts must stay server-resolvable -// so a React Server Component can reach Ask.Root and its siblings -// through them. The directive lives one level down, on ask.tsx. -export { - AskContinue as Continue, - AskDismiss as Dismiss, - AskHeader as Header, - AskHints as Hints, - AskLabel as Label, - AskNavigation as Navigation, - AskNext as Next, - AskOption as Option, - AskOptionContent as OptionContent, - AskOptionDescription as OptionDescription, - AskOptionLabel as OptionLabel, - AskOptions as Options, - AskPrevious as Previous, - AskRoot as Root, - AskStepLabel as StepLabel, -} from "./ask"; diff --git a/packages/ui/src/ask/index.ts b/packages/ui/src/ask/index.ts deleted file mode 100644 index 49563493..00000000 --- a/packages/ui/src/ask/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Public surface of @intentface/ui/ask. No directive — see index.parts.ts. - -export type { - AskContinueProps, - AskDismissProps, - AskHeaderProps, - AskHintsProps, - AskLabelProps, - AskNavigationProps, - AskNextProps, - AskOptionContentProps, - AskOptionDescriptionProps, - AskOptionLabelProps, - AskOptionProps, - AskOptionState, - AskOptionsHandle, - AskOptionsProps, - AskPreviousProps, - AskRootProps, - AskStepLabelProps, - AskStepLabelState, -} from "./ask"; -export { useAskOption, useAskOptions } from "./ask"; -export * as Ask from "./index.parts"; diff --git a/packages/ui/src/composer/actions.tsx b/packages/ui/src/composer/actions.tsx index c0c81836..0e58437d 100644 --- a/packages/ui/src/composer/actions.tsx +++ b/packages/ui/src/composer/actions.tsx @@ -35,7 +35,7 @@ export const ComposerContextWindow = ({ // Yield while any native panel is active — the strip slides back out once they // close. (Union of the native slices; no central registry.) const anyPanelActive = useComposer( - (composer) => composer.commands.active || composer.requests.active, + (composer) => composer.commands.active || composer.questions.active, ); const hasContent = Children.toArray(children).length > 0; const open = hasContent && !anyPanelActive; diff --git a/packages/ui/src/composer/index.parts.ts b/packages/ui/src/composer/index.parts.ts index bde4fd11..87a933cd 100644 --- a/packages/ui/src/composer/index.parts.ts +++ b/packages/ui/src/composer/index.parts.ts @@ -28,6 +28,23 @@ export { ComposerContainer as Container } from "./container"; export { ComposerPanel as Panel } from "./panel"; export { ComposerPlaceholder as Placeholder } from "./placeholder"; export { ComposerPopover as Popover } from "./popover"; +export { + ComposerQuestion as Question, + ComposerQuestionContinue as QuestionContinue, + ComposerQuestionDismiss as QuestionDismiss, + ComposerQuestionHeader as QuestionHeader, + ComposerQuestionHints as QuestionHints, + ComposerQuestionLabel as QuestionLabel, + ComposerQuestionNavigation as QuestionNavigation, + ComposerQuestionNext as QuestionNext, + ComposerQuestionOption as QuestionOption, + ComposerQuestionOptionContent as QuestionOptionContent, + ComposerQuestionOptionDescription as QuestionOptionDescription, + ComposerQuestionOptionLabel as QuestionOptionLabel, + ComposerQuestionOptions as QuestionOptions, + ComposerQuestionPrevious as QuestionPrevious, + ComposerQuestionStepLabel as QuestionStepLabel, +} from "./question"; export { ComposerRoot as Root } from "./root"; /** Create a standalone store handle: `` + `useComposerStore(store, selector)` + `store.controller` for imperative access. */ export { createComposerStore as createStore } from "./store"; diff --git a/packages/ui/src/composer/index.ts b/packages/ui/src/composer/index.ts index 60caeda2..d18d78de 100644 --- a/packages/ui/src/composer/index.ts +++ b/packages/ui/src/composer/index.ts @@ -12,13 +12,34 @@ export { useComposerController } from "./controller"; export { filterArrayItems, fuzzyScore, suggestionRemainder } from "./fuzzy"; export * as Composer from "./index.parts"; export type { ComposerSubmitOn, EditorKeyAction, EditorKeyContext } from "./keyboard"; -export { interpretEditorKey, interpretRequestKey } from "./keyboard"; +export { interpretEditorKey, interpretQuestionKey } from "./keyboard"; export type { ComposerPanelProps } from "./panel"; export type { ComposerPlaceholderProps } from "./placeholder"; export type { ComposerPopoverProps } from "./popover"; export type { ActiveTokenState, RegisteredPrefix } from "./prefix-detection"; export { CLOSED_COMMAND_STATE, detectActivePrefix } from "./prefix-detection"; -export type { RequestDraft } from "./request-machine"; +export type { + ComposerQuestionContinueProps, + ComposerQuestionDismissProps, + ComposerQuestionHeaderProps, + ComposerQuestionHintsProps, + ComposerQuestionLabelProps, + ComposerQuestionNavigationProps, + ComposerQuestionNextProps, + ComposerQuestionOptionContentProps, + ComposerQuestionOptionDescriptionProps, + ComposerQuestionOptionLabelProps, + ComposerQuestionOptionProps, + ComposerQuestionOptionState, + ComposerQuestionOptionsHandle, + ComposerQuestionOptionsProps, + ComposerQuestionPreviousProps, + ComposerQuestionProps, + ComposerQuestionStepLabelProps, + ComposerQuestionStepLabelState, +} from "./question"; +export { useQuestionOption, useQuestionOptions } from "./question"; +export type { AnswerDraft } from "./question-machine"; export type { ComposerRootProps } from "./root"; export type { Segment, SegmentDoc, TextChange } from "./segments"; export { @@ -33,7 +54,7 @@ export type { ComposerCommandsState, ComposerEditorState, ComposerPanelSlice, - ComposerRequestsState, + ComposerQuestionsState, ComposerState, ComposerStore, } from "./store"; @@ -44,15 +65,15 @@ export type { ChipData, CommandItemData, CommandItemKind, + ComposerAnswer, + ComposerAnswersSubmit, ComposerCommandsConfig, ComposerCommandsItems, ComposerCommandsMap, ComposerEditorHandle, ComposerMessageSubmit, - ComposerRequest, - ComposerRequestEntry, - ComposerRequestOption, - ComposerRequestsSubmit, + ComposerQuestion, + ComposerQuestionOption, ComposerSnapshot, ComposerSubmitData, PrefixOnSelectContext, diff --git a/packages/ui/src/composer/keyboard.ts b/packages/ui/src/composer/keyboard.ts index 52d0746f..4e4646f8 100644 --- a/packages/ui/src/composer/keyboard.ts +++ b/packages/ui/src/composer/keyboard.ts @@ -8,8 +8,8 @@ export type EditorKeyAction = | { type: "command-close" } | { type: "command-navigate"; direction: 1 | -1 } | { type: "command-caret"; direction: 1 | -1 } - | { type: "request-arrow"; direction: 1 | -1 } - | { type: "request-dismiss" } + | { type: "question-arrow"; direction: 1 | -1 } + | { type: "question-dismiss" } | { type: "remove-last-attachment" } | { type: "submit-form" } | { type: "soft-break" }; @@ -19,7 +19,7 @@ export type ComposerSubmitOn = "enter" | "shift-enter"; export type EditorKeyContext = { isCommandListOpen: boolean; - hasActiveRequests: boolean; + hasActiveQuestions: boolean; isEditorEmpty: boolean; hasAttachments: boolean; /** Defaults to "enter" (Enter sends, Shift+Enter breaks). */ @@ -31,7 +31,7 @@ export const interpretEditorKey = ( context: EditorKeyContext, ): EditorKeyAction | null => { const { key, shiftKey } = event; - const { isCommandListOpen, hasActiveRequests } = context; + const { isCommandListOpen, hasActiveQuestions } = context; switch (true) { case isCommandListOpen && key === "Tab": @@ -49,14 +49,14 @@ export const interpretEditorKey = ( case isCommandListOpen && key === "Enter" && !shiftKey: return { type: "command-select" }; - case hasActiveRequests && key === "ArrowUp": - return { type: "request-arrow", direction: -1 }; - case hasActiveRequests && key === "ArrowDown": - return { type: "request-arrow", direction: 1 }; - // Request-mode keys now attach to the options container (roving focus), + case hasActiveQuestions && key === "ArrowUp": + return { type: "question-arrow", direction: -1 }; + case hasActiveQuestions && key === "ArrowDown": + return { type: "question-arrow", direction: 1 }; + // Question-mode keys now attach to the options container (roving focus), // so an editor-focused Escape must dismiss from here. - case hasActiveRequests && key === "Escape": - return { type: "request-dismiss" }; + case hasActiveQuestions && key === "Escape": + return { type: "question-dismiss" }; case key === "Backspace" && context.isEditorEmpty && context.hasAttachments: return { type: "remove-last-attachment" }; @@ -77,7 +77,7 @@ export const interpretEditorKey = ( } }; -export type RequestKeyAction = +export type QuestionKeyAction = | { type: "dismiss-step" } | { type: "navigate-options"; direction: 1 | -1 } | { type: "select-option" } @@ -85,7 +85,7 @@ export type RequestKeyAction = | { type: "go-next" } | { type: "insert-character"; character: string }; -export type RequestKeyEvent = { +export type QuestionKeyEvent = { key: string; ctrlKey: boolean; metaKey: boolean; @@ -93,10 +93,10 @@ export type RequestKeyEvent = { defaultPrevented: boolean; }; -export const interpretRequestKey = ( - event: RequestKeyEvent, +export const interpretQuestionKey = ( + event: QuestionKeyEvent, context: { hasHighlight: boolean }, -): RequestKeyAction | null => { +): QuestionKeyAction | null => { const { key } = event; switch (true) { diff --git a/packages/ui/src/composer/request-machine.ts b/packages/ui/src/composer/question-machine.ts similarity index 72% rename from packages/ui/src/composer/request-machine.ts rename to packages/ui/src/composer/question-machine.ts index 43d0ef97..48371e34 100644 --- a/packages/ui/src/composer/request-machine.ts +++ b/packages/ui/src/composer/question-machine.ts @@ -1,25 +1,25 @@ -// Requests — pure state machine for the request flow. State is a current step +// Questions — pure state machine for the question flow. State is a current step // plus a map of per-step drafts; every transition is an immutable map // operation, so the store only owns the state cell and the effect execution. -import type { ComposerRequest, ComposerRequestEntry } from "./types"; +import type { ComposerAnswer, ComposerQuestion } from "./types"; // --------------------------------------------------------------------------- // Types — the machine state plus the effect/action/transition vocabulary the // store speaks to it in. // --------------------------------------------------------------------------- -export type RequestDraft = { +export type AnswerDraft = { selected: Set; freeText: string; }; -export type RequestState = { +export type QuestionsState = { step: number; - drafts: Map; + drafts: Map; }; -export const INITIAL_REQUEST_STATE: RequestState = { +export const INITIAL_QUESTIONS_STATE: QuestionsState = { step: 0, drafts: new Map(), }; @@ -27,15 +27,15 @@ export const INITIAL_REQUEST_STATE: RequestState = { // Everything a transition may ask of the outside world. The store executes // these against the editor controller, the options handle, and the submit // callback — the transitions below only describe them. -export type RequestEffect = +export type QuestionsEffect = | { type: "clear-input" } | { type: "set-input-text"; text: string } | { type: "focus-input" } | { type: "focus-options" } | { type: "reset-highlight" } - | { type: "submit-requests"; requests: ComposerRequestEntry[] }; + | { type: "submit-answers"; answers: ComposerAnswer[] }; -export type RequestAction = +export type QuestionsAction = | { type: "toggle-option"; label: string } | { type: "select-option"; label: string } | { type: "clear-selections" } @@ -44,9 +44,9 @@ export type RequestAction = | { type: "step-back"; currentText: string } | { type: "step-forward"; currentText: string }; -export type RequestTransition = { - next: RequestState; - effects: RequestEffect[]; +export type QuestionsTransition = { + next: QuestionsState; + effects: QuestionsEffect[]; }; // --------------------------------------------------------------------------- @@ -54,19 +54,19 @@ export type RequestTransition = { // projection/query the store reads. // --------------------------------------------------------------------------- -const emptyDraft = (): RequestDraft => ({ +const emptyDraft = (): AnswerDraft => ({ selected: new Set(), freeText: "", }); -const cloneDrafts = (source: Map) => new Map(source); +const cloneDrafts = (source: Map) => new Map(source); // Record `text` as the step's free-text answer. Single-select treats text and // a chosen option as mutually exclusive, so any selection is dropped; // multi-select keeps existing selections alongside the text. Empty text is a // no-op (returns an untouched clone). const writeFreeText = ( - drafts: Map, + drafts: Map, step: number, text: string, multiSelect: boolean, @@ -82,7 +82,7 @@ const writeFreeText = ( }; // Remove the step's draft entirely (skip / dismiss). -const skipStep = (drafts: Map, step: number): Map => { +const skipStep = (drafts: Map, step: number): Map => { const next = cloneDrafts(drafts); next.delete(step); return next; @@ -92,7 +92,7 @@ const skipStep = (drafts: Map, step: number): Map, + drafts: Map, step: number, label: string, multiSelect: boolean, @@ -111,49 +111,49 @@ const toggleAnswer = ( return next; }; -// Project the drafts onto the public flat entry shape, one entry per request in +// Project the drafts onto the public flat entry shape, one entry per question in // order. Selected keys resolve to option values (label fallback); `text` is // present only when the user typed something. -export const compileRequests = ( - state: RequestState, - requests: ComposerRequest[], -): ComposerRequestEntry[] => - requests.map((request, index) => { +export const compileAnswers = ( + state: QuestionsState, + questions: ComposerQuestion[], +): ComposerAnswer[] => + questions.map((question, index) => { const draft = state.drafts.get(index); - if (!draft) return { id: request.id, selected: [] }; + if (!draft) return { id: question.id, selected: [] }; // Exact identity first, then a label fallback for consumers who wire // `value={option.label}`. Never match value-against-label, so one option's // label cannot resolve to another's value. const selected = [...draft.selected].map((key) => { const option = - request.options.find((o) => (o.value ?? o.label) === key) ?? - request.options.find((o) => o.label === key); + question.options.find((o) => (o.value ?? o.label) === key) ?? + question.options.find((o) => o.label === key); return option ? (option.value ?? option.label) : key; }); const text = draft.freeText.trim(); - return text === "" ? { id: request.id, selected } : { id: request.id, selected, text }; + return text === "" ? { id: question.id, selected } : { id: question.id, selected, text }; }); -export const isLastStep = (state: RequestState, requests: ComposerRequest[]) => - state.step >= requests.length - 1; +export const isLastStep = (state: QuestionsState, questions: ComposerQuestion[]) => + state.step >= questions.length - 1; // --------------------------------------------------------------------------- // Transitions — the decide half of the flow: map an action onto the next state -// plus the effects to run. transitionRequests is the single entry point. +// plus the effects to run. transitionQuestions is the single entry point. // --------------------------------------------------------------------------- // Commit `nextDrafts`, advance one step, and reset the input — then either -// arm the next request (blurred, highlight reset) or compile and submit on +// arm the next question (blurred, highlight reset) or compile and submit on // the last step (input focused for the follow-up message). const advanceStep = ( - state: RequestState, - requests: ComposerRequest[], - nextDrafts: Map, -): RequestTransition => { - const next: RequestState = { step: state.step + 1, drafts: nextDrafts }; - if (state.step < requests.length - 1) { + state: QuestionsState, + questions: ComposerQuestion[], + nextDrafts: Map, +): QuestionsTransition => { + const next: QuestionsState = { step: state.step + 1, drafts: nextDrafts }; + if (state.step < questions.length - 1) { return { next, effects: [{ type: "clear-input" }, { type: "reset-highlight" }, { type: "focus-options" }], @@ -163,7 +163,7 @@ const advanceStep = ( next, effects: [ { type: "clear-input" }, - { type: "submit-requests", requests: compileRequests(next, requests) }, + { type: "submit-answers", answers: compileAnswers(next, questions) }, { type: "focus-input" }, ], }; @@ -172,10 +172,10 @@ const advanceStep = ( // Navigate to `targetStep`, preserving any in-progress free text on the step // being left and restoring the target step's saved text into the input. const transitionToStep = ( - state: RequestState, + state: QuestionsState, targetStep: number, currentText: string, -): RequestTransition => { +): QuestionsTransition => { if (targetStep === state.step) return { next: state, effects: [] }; const trimmed = currentText.trim(); @@ -185,7 +185,7 @@ const transitionToStep = ( drafts.set(state.step, { ...previous, freeText: trimmed }); } - const next: RequestState = { step: targetStep, drafts }; + const next: QuestionsState = { step: targetStep, drafts }; return { next, effects: [ @@ -196,16 +196,16 @@ const transitionToStep = ( }; }; -// The decide half of the request flow, mirroring interpretRequestKey: map an +// The decide half of the question flow, mirroring interpretQuestionKey: map an // action onto the next state plus the effects to run. Composed actions // (select-option = toggle + advance) resolve atomically here, so no caller // ever chains transitions across a stale state snapshot. -export const transitionRequests = ( - state: RequestState, - requests: ComposerRequest[], - action: RequestAction, -): RequestTransition => { - const multiSelect = !!requests[state.step]?.multiSelect; +export const transitionQuestions = ( + state: QuestionsState, + questions: ComposerQuestion[], + action: QuestionsAction, +): QuestionsTransition => { + const multiSelect = !!questions[state.step]?.multiSelect; switch (action.type) { case "toggle-option": @@ -220,7 +220,7 @@ export const transitionRequests = ( case "select-option": { const toggled = toggleAnswer(state.drafts, state.step, action.label, multiSelect); if (multiSelect) return { next: { ...state, drafts: toggled }, effects: [] }; - return advanceStep(state, requests, toggled); + return advanceStep(state, questions, toggled); } case "clear-selections": { @@ -234,12 +234,12 @@ export const transitionRequests = ( case "continue-step": return advanceStep( state, - requests, + questions, writeFreeText(state.drafts, state.step, action.freeText.trim(), multiSelect), ); case "dismiss-step": - return advanceStep(state, requests, skipStep(state.drafts, state.step)); + return advanceStep(state, questions, skipStep(state.drafts, state.step)); case "step-back": return transitionToStep(state, Math.max(0, state.step - 1), action.currentText); @@ -247,7 +247,7 @@ export const transitionRequests = ( case "step-forward": return transitionToStep( state, - Math.min(requests.length - 1, state.step + 1), + Math.min(questions.length - 1, state.step + 1), action.currentText, ); } diff --git a/packages/ui/src/ask/ask.tsx b/packages/ui/src/composer/question.tsx similarity index 70% rename from packages/ui/src/ask/ask.tsx rename to packages/ui/src/composer/question.tsx index b0e33edd..91c48404 100644 --- a/packages/ui/src/ask/ask.tsx +++ b/packages/ui/src/composer/question.tsx @@ -1,10 +1,10 @@ "use client"; -// Headless ask parts — the request flow widget. Owns the option registration system +// Headless ask parts — the question flow widget. Owns the option registration system // (cmdk pattern), highlight state, and the imperative keyboard-navigation // handle. Input widgets (checkbox/radio), icons, and the answered summary -// belong to the styled layer, which reads state via useAskOptions() / -// useAskOption(). Every part supports the Base UI render prop. +// belong to the styled layer, which reads state via useQuestionOptions() / +// useQuestionOption(). Every part supports the Base UI render prop. import { createContext, @@ -20,104 +20,139 @@ import { useRef, useState, } from "react"; -import { useComposer } from "../composer/store"; import type { PrimitiveProps } from "../internal/primitive-props"; import { useRenderElement } from "../internal/render/useRenderElement"; import { scrollIntoViewWithin } from "../internal/scroll-into-view-within"; +import { useComposer } from "./store"; -// Shared ids so Options can label itself from the request label and describe +// Shared ids so Options can label itself from the question label and describe // itself from the step indicator without any consumer wiring: Root mints both, // Label/StepLabel stamp them, Options references them. -type AskIdsContextValue = { labelId: string; stepLabelId: string }; +type QuestionIdsContextValue = { labelId: string; stepLabelId: string }; -const AskIdsContext = createContext({ labelId: "", stepLabelId: "" }); +const QuestionIdsContext = createContext({ labelId: "", stepLabelId: "" }); /** Ask root container. Stateless — consumers manage all state externally. */ -export type AskRootProps = PrimitiveProps<"div">; +export type ComposerQuestionProps = PrimitiveProps<"div">; -export const AskRoot = ({ className, render, style, ...elementProps }: AskRootProps) => { +export const ComposerQuestion = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionProps) => { const labelId = useId(); const stepLabelId = useId(); const element = useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask": "" }, elementProps] }, + { props: [{ "data-composer-question": "" }, elementProps] }, ); const ids = useMemo(() => ({ labelId, stepLabelId }), [labelId, stepLabelId]); - return {element}; + return {element}; }; -/** Request label text. Carries the id `Options` uses as its accessible name. */ -export type AskLabelProps = PrimitiveProps<"p">; +/** Question label text. Carries the id `Options` uses as its accessible name. */ +export type ComposerQuestionLabelProps = PrimitiveProps<"p">; -export const AskLabel = ({ className, render, style, ...elementProps }: AskLabelProps) => { - const { labelId } = use(AskIdsContext); +export const ComposerQuestionLabel = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionLabelProps) => { + const { labelId } = use(QuestionIdsContext); return useRenderElement( "p", { className, render, style }, - { props: [{ id: labelId || undefined, "data-ask-label": "" }, elementProps] }, + { props: [{ id: labelId || undefined, "data-composer-question-label": "" }, elementProps] }, ); }; /** Row container for `Label` and optional `Navigation`. */ -export type AskHeaderProps = PrimitiveProps<"div">; +export type ComposerQuestionHeaderProps = PrimitiveProps<"div">; -export const AskHeader = ({ className, render, style, ...elementProps }: AskHeaderProps) => +export const ComposerQuestionHeader = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionHeaderProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-header": "" }, elementProps] }, + { props: [{ "data-composer-question-header": "" }, elementProps] }, ); /** Row container for `Previous`, `StepLabel`, and `Next`. */ -export type AskNavigationProps = PrimitiveProps<"div">; +export type ComposerQuestionNavigationProps = PrimitiveProps<"div">; -export const AskNavigation = ({ className, render, style, ...elementProps }: AskNavigationProps) => +export const ComposerQuestionNavigation = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionNavigationProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-navigation": "" }, elementProps] }, + { props: [{ "data-composer-question-navigation": "" }, elementProps] }, ); /** Navigate to the previous step. Default accessible name; override via aria-label. */ -export type AskPreviousProps = PrimitiveProps<"button">; +export type ComposerQuestionPreviousProps = PrimitiveProps<"button">; -export const AskPrevious = ({ className, render, style, ...elementProps }: AskPreviousProps) => +export const ComposerQuestionPrevious = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionPreviousProps) => useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Previous request", "data-ask-previous": "" }, elementProps] }, + { + props: [ + { "aria-label": "Previous question", "data-composer-question-previous": "" }, + elementProps, + ], + }, ); /** Navigate to the next step. Default accessible name; override via aria-label. */ -export type AskNextProps = PrimitiveProps<"button">; +export type ComposerQuestionNextProps = PrimitiveProps<"button">; -export const AskNext = ({ className, render, style, ...elementProps }: AskNextProps) => +export const ComposerQuestionNext = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionNextProps) => useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Next request", "data-ask-next": "" }, elementProps] }, + { props: [{ "aria-label": "Next question", "data-composer-question-next": "" }, elementProps] }, ); /** Step indicator. Reads the current step + total from the composer store; the * consumer supplies the text via `children` — either a node, or a callback * receiving `{ current, total }` (1-based current). No default format. */ -export type AskStepLabelState = { current: number; total: number }; +export type ComposerQuestionStepLabelState = { current: number; total: number }; -export type AskStepLabelProps = Omit, "children"> & { - children?: ReactNode | ((state: AskStepLabelState) => ReactNode); +export type ComposerQuestionStepLabelProps = Omit, "children"> & { + children?: ReactNode | ((state: ComposerQuestionStepLabelState) => ReactNode); }; -export const AskStepLabel = ({ +export const ComposerQuestionStepLabel = ({ children, className, render, style, ...elementProps -}: AskStepLabelProps) => { - const current = useComposer((composer) => composer.requests.step) + 1; - const total = useComposer((composer) => composer.requests.items?.length ?? 0); - const { stepLabelId } = use(AskIdsContext); +}: ComposerQuestionStepLabelProps) => { + const current = useComposer((composer) => composer.questions.step) + 1; + const total = useComposer((composer) => composer.questions.items?.length ?? 0); + const { stepLabelId } = use(QuestionIdsContext); const content = typeof children === "function" ? children({ current, total }) : children; return useRenderElement( @@ -125,7 +160,11 @@ export const AskStepLabel = ({ { className, render, style }, { props: [ - { id: stepLabelId || undefined, "data-ask-step-label": "", children: content }, + { + id: stepLabelId || undefined, + "data-composer-question-step-label": "", + children: content, + }, elementProps, ], }, @@ -151,10 +190,10 @@ const OptionsContext = createContext({ onItemHover: () => {}, }); -export const useAskOptions = () => use(OptionsContext); +export const useQuestionOptions = () => use(OptionsContext); -/** Imperative handle exposed by `Ask.Options` for keyboard navigation. */ -export type AskOptionsHandle = { +/** Imperative handle exposed by `Composer.QuestionOptions` for keyboard navigation. */ +export type ComposerQuestionOptionsHandle = { /** Move highlight by direction, focusing the newly highlighted option (roving tabindex). Returns the new highlighted value (null = past the list boundary). */ navigate: (direction: number) => string | null; select: () => { value: string } | null; @@ -162,21 +201,21 @@ export type AskOptionsHandle = { resetHighlight: () => void; /** Move DOM focus onto the highlighted option (or the first option when none is highlighted). */ focusHighlighted: () => void; - /** The options container element — the composer attaches its request-mode keydown handling here. */ + /** The options container element — the composer attaches its question-mode keydown handling here. */ getElement: () => HTMLElement | null; highlightedValue: string | null; }; -export type AskOptionsProps = Omit, "value" | "ref"> & { +export type ComposerQuestionOptionsProps = Omit, "value" | "ref"> & { /** When true, options render as multi-select (checkbox semantics in the styled layer). */ multiSelect?: boolean; /** Shared `name` attribute for all option inputs in this group. */ groupName?: string; /** Imperative ref for keyboard navigation (navigate, select, clearHighlight, resetHighlight). */ - ref?: RefObject; + ref?: RefObject; }; -export const AskOptions = ({ +export const ComposerQuestionOptions = ({ multiSelect = false, groupName = "", ref, @@ -185,11 +224,11 @@ export const AskOptions = ({ style, children, ...elementProps -}: AskOptionsProps) => { +}: ComposerQuestionOptionsProps) => { const registeredItems = useRef([]); const itemElements = useRef HTMLElement | null>>(new Map()); const containerRef = useRef(null); - const { labelId, stepLabelId } = use(AskIdsContext); + const { labelId, stepLabelId } = use(QuestionIdsContext); const [highlightedValue, setHighlightedValue] = useState(null); // Track highlight in a ref so imperative methods see latest value without re-binding. @@ -237,7 +276,7 @@ export const AskOptions = ({ const element = itemElements.current.get(itemValue)?.(); if (!element) return; element.focus({ preventScroll: true }); - const boundary = element.closest("[data-ask]") ?? element; + const boundary = element.closest("[data-composer-question]") ?? element; scrollIntoViewWithin(element.getBoundingClientRect(), element, boundary); }, []); @@ -305,7 +344,7 @@ export const AskOptions = ({ role: multiSelect ? "group" : "radiogroup", "aria-labelledby": labelId || undefined, "aria-describedby": stepLabelId || undefined, - "data-ask-options": "", + "data-composer-question-options": "", children, }, elementProps, @@ -343,22 +382,22 @@ const OptionContext = createContext({ onSelect: () => {}, }); -export const useAskOption = () => use(OptionContext); +export const useQuestionOption = () => use(OptionContext); -export type AskOptionState = { +export type ComposerQuestionOptionState = { /** Present as data-highlighted while keyboard/hover highlighted. */ highlighted: boolean; /** Present as data-selected while the option is selected. */ selected: boolean; }; -export type AskOptionProps = PrimitiveProps<"label", AskOptionState> & { +export type ComposerQuestionOptionProps = PrimitiveProps<"label", ComposerQuestionOptionState> & { value?: string; selected?: boolean; onSelect?: () => void; }; -export const AskOption = ({ +export const ComposerQuestionOption = ({ value = "", selected = false, onSelect, @@ -367,7 +406,7 @@ export const AskOption = ({ style, children, ...elementProps -}: AskOptionProps) => { +}: ComposerQuestionOptionProps) => { const id = useId(); const { multiSelect, highlightedValue, register, onItemHover } = use(OptionsContext); const optionRef = useRef(null); @@ -391,12 +430,12 @@ export const AskOption = ({ // The option IS the selectable control: role + checked state live // here, and the roving tabindex makes the highlighted option the // group's single tab stop. Never a native input — the composer's - // request-mode key scoping treats inputs as foreign editables. + // question-mode key scoping treats inputs as foreign editables. id, role: multiSelect ? "checkbox" : "radio", "aria-checked": selected, tabIndex: isHighlighted ? 0 : -1, - "data-ask-option": "", + "data-composer-question-option": "", onMouseMove: () => onItemHover(value), // Keep highlight and DOM focus unified when focus arrives by other // means (Tab into the group, SR virtual-cursor activation). @@ -439,76 +478,91 @@ const useItemRegistration = ( }; /** Flex column wrapper for `OptionLabel` and `OptionDescription`. */ -export type AskOptionContentProps = PrimitiveProps<"span">; +export type ComposerQuestionOptionContentProps = PrimitiveProps<"span">; -export const AskOptionContent = ({ +export const ComposerQuestionOptionContent = ({ className, render, style, ...elementProps -}: AskOptionContentProps) => +}: ComposerQuestionOptionContentProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-option-content": "" }, elementProps] }, + { props: [{ "data-composer-question-option-content": "" }, elementProps] }, ); /** Option title text. */ -export type AskOptionLabelProps = PrimitiveProps<"span">; +export type ComposerQuestionOptionLabelProps = PrimitiveProps<"span">; -export const AskOptionLabel = ({ +export const ComposerQuestionOptionLabel = ({ className, render, style, ...elementProps -}: AskOptionLabelProps) => +}: ComposerQuestionOptionLabelProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-option-label": "" }, elementProps] }, + { props: [{ "data-composer-question-option-label": "" }, elementProps] }, ); /** Option subtitle/description text. */ -export type AskOptionDescriptionProps = PrimitiveProps<"span">; +export type ComposerQuestionOptionDescriptionProps = PrimitiveProps<"span">; -export const AskOptionDescription = ({ +export const ComposerQuestionOptionDescription = ({ className, render, style, ...elementProps -}: AskOptionDescriptionProps) => +}: ComposerQuestionOptionDescriptionProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-option-description": "" }, elementProps] }, + { props: [{ "data-composer-question-option-description": "" }, elementProps] }, ); /** Keyboard shortcut hints displayed below the ask options. */ -export type AskHintsProps = PrimitiveProps<"div">; +export type ComposerQuestionHintsProps = PrimitiveProps<"div">; -export const AskHints = ({ className, render, style, ...elementProps }: AskHintsProps) => +export const ComposerQuestionHints = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionHintsProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-hints": "" }, elementProps] }, + { props: [{ "data-composer-question-hints": "" }, elementProps] }, ); /** Dismiss/skip button. Stateless — wire `onClick` to your dismiss handler. */ -export type AskDismissProps = PrimitiveProps<"button">; +export type ComposerQuestionDismissProps = PrimitiveProps<"button">; -export const AskDismiss = ({ className, render, style, ...elementProps }: AskDismissProps) => +export const ComposerQuestionDismiss = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionDismissProps) => useRenderElement( "button", { className, render, style }, - { props: [{ type: "button" as const, "data-ask-dismiss": "" }, elementProps] }, + { props: [{ type: "button" as const, "data-composer-question-dismiss": "" }, elementProps] }, ); /** Continue/submit button — `type=submit` so the enclosing form drives it. */ -export type AskContinueProps = PrimitiveProps<"button">; +export type ComposerQuestionContinueProps = PrimitiveProps<"button">; -export const AskContinue = ({ className, render, style, ...elementProps }: AskContinueProps) => +export const ComposerQuestionContinue = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionContinueProps) => useRenderElement( "button", { className, render, style }, - { props: [{ type: "submit" as const, "data-ask-continue": "" }, elementProps] }, + { props: [{ type: "submit" as const, "data-composer-question-continue": "" }, elementProps] }, ); diff --git a/packages/ui/src/composer/root.tsx b/packages/ui/src/composer/root.tsx index 07c89415..ba9a11c0 100644 --- a/packages/ui/src/composer/root.tsx +++ b/packages/ui/src/composer/root.tsx @@ -20,7 +20,7 @@ import { import { type ComposerStore, ComposerStoreContext, createComposerStore } from "./store"; import type { ComposerCommandsMap, - ComposerRequest, + ComposerQuestion, ComposerSnapshot, ComposerSubmitData, } from "./types"; @@ -38,7 +38,7 @@ export type ComposerRootProps = Omit, onSubmit?: (data: ComposerSubmitData) => void | Promise; isSubmitting?: boolean; commands?: ComposerCommandsMap; - requests?: ComposerRequest[]; + questions?: ComposerQuestion[]; defaultValue?: ComposerSnapshot; value?: ComposerSnapshot; onValueChange?: (snapshot: ComposerSnapshot) => void; @@ -50,7 +50,7 @@ export const ComposerRoot = ({ onSubmit, isSubmitting = false, commands = EMPTY_COMMANDS, - requests, + questions, defaultValue, value, onValueChange, @@ -78,17 +78,17 @@ export const ComposerRoot = ({ const onSubmitRef = useAsRef(onSubmit); - // Register this mount on the store: request entries submit through this - // mount's onSubmit. React also runs the cleanup without a real unmount + // Register this mount on the store: answers submit through this mount's + // onSubmit. React also runs the cleanup without a real unmount // (StrictMode, a hidden ), where the state lives on, so it only // resets an owned store once React has removed the form — that happens on a // real unmount only. useEffect(() => { const form = formRef.current; - store.submitRequestsRef.current = (requests) => - onSubmitRef.current?.({ kind: "requests", requests }); + store.submitAnswersRef.current = (answers) => + onSubmitRef.current?.({ kind: "answers", answers }); return () => { - store.submitRequestsRef.current = null; + store.submitAnswersRef.current = null; if (ownsStore && form && !form.isConnected) store.reset(); }; }, [store, ownsStore]); @@ -106,16 +106,16 @@ export const ComposerRoot = ({ store.setIsSubmitting(isSubmitting); }, [store, isSubmitting]); - // Sync the requests prop into the store and arm request mode (editor blur - // + document-level keyboard handling) while requests are active. An empty + // Sync the questions prop into the store and arm question mode (editor blur + // + document-level keyboard handling) while questions are active. An empty // array is normalized to null — otherwise the slice reads active with no // content to render and no keyboard handler attached. - const activeRequests = requests?.length ? requests : null; + const activeQuestions = questions?.length ? questions : null; useEffect(() => { - store.setRequests(activeRequests); - if (!activeRequests) return; - return store.activateRequests(); - }, [store, activeRequests]); + store.setQuestions(activeQuestions); + if (!activeQuestions) return; + return store.activateQuestions(); + }, [store, activeQuestions]); const { getRegisteredPrefixes } = useCommandRegistry(commands); @@ -135,10 +135,10 @@ export const ComposerRoot = ({ const handleFormSubmit = async (event: React.SubmitEvent) => { event.preventDefault(); - const { requests, attachments } = store.getSnapshot(); + const { questions, attachments } = store.getSnapshot(); - if (requests.items?.length) { - requests.continueStep(store.controller.getText()); + if (questions.items?.length) { + questions.continueStep(store.controller.getText()); return; } @@ -169,8 +169,8 @@ export const ComposerRoot = ({ const handleKeyDown = (event: React.KeyboardEvent) => { const stopGenerating = store.stopGeneratingRef.current; if (event.key !== "Escape" || event.defaultPrevented || !stopGenerating) return; - // While a request is open, Escape dismisses its step instead. - if (store.getSnapshot().requests.items?.length) return; + // While a question is open, Escape dismisses its step instead. + if (store.getSnapshot().questions.items?.length) return; event.preventDefault(); stopGenerating(); }; diff --git a/packages/ui/src/composer/store.ts b/packages/ui/src/composer/store.ts index 4df88f49..5eef3062 100644 --- a/packages/ui/src/composer/store.ts +++ b/packages/ui/src/composer/store.ts @@ -15,7 +15,6 @@ // snapshot. import { createContext, type RefObject, use, useSyncExternalStore } from "react"; -import type { AskOptionsHandle } from "../ask"; import { type AttachmentErrorCode, type AttachmentItem, @@ -28,19 +27,20 @@ import { attachmentReducer, INITIAL_ATTACHMENT_STATE, } from "./attachments-machine"; -import { interpretRequestKey } from "./keyboard"; +import { interpretQuestionKey } from "./keyboard"; +import type { ComposerQuestionOptionsHandle } from "./question"; import { - INITIAL_REQUEST_STATE, + type AnswerDraft, + INITIAL_QUESTIONS_STATE, isLastStep, - type RequestAction, - type RequestDraft, - type RequestEffect, - transitionRequests, -} from "./request-machine"; + type QuestionsAction, + type QuestionsEffect, + transitionQuestions, +} from "./question-machine"; import type { + ComposerAnswer, ComposerEditorHandle, - ComposerRequest, - ComposerRequestEntry, + ComposerQuestion, RegisteredEditor, } from "./types"; @@ -90,10 +90,10 @@ export type ComposerAttachmentsState = { globalDropRef: RefObject; }; -export type ComposerRequestsState = ComposerPanelSlice & { - items: ComposerRequest[] | null; +export type ComposerQuestionsState = ComposerPanelSlice & { + items: ComposerQuestion[] | null; step: number; - drafts: Map; + drafts: Map; toggleOption: (label: string) => void; continueStep: (freeText?: string) => void; dismissStep: () => void; @@ -102,10 +102,10 @@ export type ComposerRequestsState = ComposerPanelSlice & { clearSelections: () => void; goBack: () => void; goNext: () => void; - optionsRef: RefObject; + optionsRef: RefObject; }; -// The shared skeleton every native panel slice (commands, requests) is built +// The shared skeleton every native panel slice (commands, questions) is built // on: `active` is the logical open flag a consumer gates // on (`{commands.active && <…/>}`); `present` is sticky-true — it stays set through // the close animation so the panel can keep the last content mounted while it @@ -135,7 +135,7 @@ export type ComposerState = { isSubmitting: boolean; commands: ComposerCommandsState; attachments: ComposerAttachmentsState; - requests: ComposerRequestsState; + questions: ComposerQuestionsState; }; // --------------------------------------------------------------------------- @@ -159,10 +159,10 @@ export type ComposerStore = { listboxId: string; moveHighlight: (direction: number) => void; setHighlight: (index: number) => void; - setRequests: (requests: ComposerRequest[] | null) => void; + setQuestions: (questions: ComposerQuestion[] | null) => void; setDragging: (active: boolean) => void; resetAttachments: () => void; - activateRequests: () => () => void; + activateQuestions: () => () => void; // Clears `present` on native panel slices whose `active` is false — the Panel calls // this once its close animation finishes, so closing content stays mounted until then. finalizePanelClose: () => void; @@ -180,7 +180,7 @@ export type ComposerStore = { containerRef: RefObject; // Co-located refs the mounted Composer wires up at runtime. attachmentConfigRef: RefObject; - submitRequestsRef: RefObject<((requests: ComposerRequestEntry[]) => void) | null>; + submitAnswersRef: RefObject<((answers: ComposerAnswer[]) => void) | null>; // The mounted Submit's stop while generating; Composer.Root calls it on Escape. stopGeneratingRef: RefObject<(() => void) | null>; // Invokes the active list's current selection. Registered by the mounted @@ -189,11 +189,11 @@ export type ComposerStore = { }; // --------------------------------------------------------------------------- -// Request keydown — the document-level handler for request mode, kept out of -// the store factory so activateRequests reads as just "blur, listen, unlisten". +// Question keydown — the document-level handler for question mode, kept out of +// the store factory so activateQuestions reads as just "blur, listen, unlisten". // --------------------------------------------------------------------------- -// Focus moves the request flow makes itself — back to the input, or in from +// Focus moves the question flow makes itself — back to the input, or in from // an option. The composer is on screen already, and the browser's // focus-scroll would also move the page around an embedded composer. const FOCUS_IN_PLACE: FocusOptions = { preventScroll: true }; @@ -210,7 +210,7 @@ const isEventForComposer = ( const target = event.target as HTMLElement | null; const editableHost = target?.closest('input, textarea, [contenteditable="true"]'); if (editableHost) return editableHost === editorDom; - const optionsHost = target?.closest("[data-ask-options]"); + const optionsHost = target?.closest("[data-composer-question-options]"); if (optionsHost) return optionsHost === optionsElement; // Only the body fallback the comment above describes. Anything else is a real // element that owns its own keys — Space on an unrelated button must not @@ -218,17 +218,17 @@ const isEventForComposer = ( return target === null || target === document.body; }; -type RequestKeydownDeps = { +type QuestionsKeydownDeps = { controller: ComposerEditorState; - optionsRef: RefObject; + optionsRef: RefObject; editorRef: RefObject; - dispatch: (action: RequestAction) => void; + dispatch: (action: QuestionsAction) => void; }; // Interpret a key (pure) and drive the machine + editor controller. Mirrors the -// action union from interpretRequestKey one-to-one. -const createRequestKeydownHandler = - ({ controller, optionsRef, editorRef, dispatch }: RequestKeydownDeps) => +// action union from interpretQuestionKey one-to-one. +const createQuestionsKeydownHandler = + ({ controller, optionsRef, editorRef, dispatch }: QuestionsKeydownDeps) => (event: KeyboardEvent) => { const scopedOptionsElement = optionsRef.current?.getElement() ?? null; if ( @@ -242,7 +242,7 @@ const createRequestKeydownHandler = } const optionsHandle = optionsRef.current; - const action = interpretRequestKey( + const action = interpretQuestionKey( { key: event.key, ctrlKey: event.ctrlKey, @@ -306,7 +306,7 @@ export const createComposerStore = (): ComposerStore => { // Imperative refs co-located with the store; not reactive. const containerRef: RefObject = { current: null }; - const optionsRef: RefObject = { current: null }; + const optionsRef: RefObject = { current: null }; const fileInputRef: RefObject = { current: null }; const globalDropRef: RefObject = { current: false }; // Permissive defaults: accept everything, no caps, platform-default blob @@ -321,7 +321,7 @@ export const createComposerStore = (): ComposerStore => { destroy: revokeAttachmentUrl, }, }; - const submitRequestsRef: RefObject<((requests: ComposerRequestEntry[]) => void) | null> = { + const submitAnswersRef: RefObject<((answers: ComposerAnswer[]) => void) | null> = { current: null, }; const commandSelectRef: RefObject<(() => void) | null> = { current: null }; @@ -329,7 +329,7 @@ export const createComposerStore = (): ComposerStore => { // Canonical machine states; the snapshot mirrors them on every update. let attachmentState = INITIAL_ATTACHMENT_STATE; - let requestsMachine = INITIAL_REQUEST_STATE; + let questionsMachine = INITIAL_QUESTIONS_STATE; let snapshot: ComposerState; // --- Setters @@ -417,12 +417,12 @@ export const createComposerStore = (): ComposerStore => { notify(); }; - // --- Requests - // The execute half of the request flow: replay a transition's effects + // --- Questions + // The execute half of the question flow: replay a transition's effects // against the editor controller, the options handle, and the submit // callback. Input-content state is the editor engine's own job — engines // report programmatic setText/clear through their update path. - const executeRequestEffects = (effects: RequestEffect[]) => { + const executeQuestionsEffects = (effects: QuestionsEffect[]) => { for (const effect of effects) { switch (effect.type) { case "clear-input": @@ -444,68 +444,68 @@ export const createComposerStore = (): ComposerStore => { case "reset-highlight": optionsRef.current?.resetHighlight(); break; - case "submit-requests": - submitRequestsRef.current?.(effect.requests); + case "submit-answers": + submitAnswersRef.current?.(effect.answers); break; } } }; - const dispatchRequests = (action: RequestAction) => { - const requests = snapshot.requests.items; - if (!requests || requests.length === 0) return; - const { next, effects } = transitionRequests(requestsMachine, requests, action); - if (next !== requestsMachine) { - requestsMachine = next; + const dispatchQuestions = (action: QuestionsAction) => { + const questions = snapshot.questions.items; + if (!questions || questions.length === 0) return; + const { next, effects } = transitionQuestions(questionsMachine, questions, action); + if (next !== questionsMachine) { + questionsMachine = next; snapshot = { ...snapshot, - requests: { - ...snapshot.requests, + questions: { + ...snapshot.questions, step: next.step, drafts: next.drafts, - isLastStep: isLastStep(next, requests), + isLastStep: isLastStep(next, questions), }, }; notify(); } - executeRequestEffects(effects); + executeQuestionsEffects(effects); }; - const setRequests = (requests: ComposerRequest[] | null) => { - if (snapshot.requests.items === requests) return; - requestsMachine = INITIAL_REQUEST_STATE; + const setQuestions = (questions: ComposerQuestion[] | null) => { + if (snapshot.questions.items === questions) return; + questionsMachine = INITIAL_QUESTIONS_STATE; snapshot = { ...snapshot, - requests: { - ...snapshot.requests, - active: requests != null, + questions: { + ...snapshot.questions, + active: questions != null, // Sticky, same as commands: cleared by finalizePanelClose() after the exit. - present: requests != null || snapshot.requests.present, - items: requests, - step: requestsMachine.step, - drafts: requestsMachine.drafts, - isLastStep: requests ? isLastStep(requestsMachine, requests) : false, - isSingle: requests ? requests.length === 1 : false, + present: questions != null || snapshot.questions.present, + items: questions, + step: questionsMachine.step, + drafts: questionsMachine.drafts, + isLastStep: questions ? isLastStep(questionsMachine, questions) : false, + isSingle: questions ? questions.length === 1 : false, }, }; notify(); }; - // Request mode: while requests are active the options own the keyboard. + // Question mode: while questions are active the options own the keyboard. // Entering moves DOM focus onto the highlighted option (roving tabindex), // and the keydown listener sits at the document, scoped by containment // (isEventForComposer) — so keys keep flowing after a chrome click drops // focus to , while a second composer's editor/options never hear them. - const activateRequests = () => { - const handleKeyDown = createRequestKeydownHandler({ + const activateQuestions = () => { + const handleKeyDown = createQuestionsKeydownHandler({ controller, optionsRef, editorRef, - dispatch: dispatchRequests, + dispatch: dispatchQuestions, }); document.addEventListener("keydown", handleKeyDown); - // The Options part renders in reaction to setRequests' notify — one + // The Options part renders in reaction to setQuestions' notify — one // commit after this call — so the entry focus defers a frame. const frame = requestAnimationFrame(() => { optionsRef.current?.focusHighlighted(); @@ -539,21 +539,21 @@ export const createComposerStore = (): ComposerStore => { fileInputRef, globalDropRef, }, - requests: { + questions: { active: false, present: false, items: null, - step: requestsMachine.step, - drafts: requestsMachine.drafts, + step: questionsMachine.step, + drafts: questionsMachine.drafts, isLastStep: false, isSingle: false, - toggleOption: (label) => dispatchRequests({ type: "toggle-option", label }), + toggleOption: (label) => dispatchQuestions({ type: "toggle-option", label }), continueStep: (freeText) => - dispatchRequests({ type: "continue-step", freeText: freeText ?? "" }), - dismissStep: () => dispatchRequests({ type: "dismiss-step" }), - clearSelections: () => dispatchRequests({ type: "clear-selections" }), - goBack: () => dispatchRequests({ type: "step-back", currentText: controller.getText() }), - goNext: () => dispatchRequests({ type: "step-forward", currentText: controller.getText() }), + dispatchQuestions({ type: "continue-step", freeText: freeText ?? "" }), + dismissStep: () => dispatchQuestions({ type: "dismiss-step" }), + clearSelections: () => dispatchQuestions({ type: "clear-selections" }), + goBack: () => dispatchQuestions({ type: "step-back", currentText: controller.getText() }), + goNext: () => dispatchQuestions({ type: "step-forward", currentText: controller.getText() }), optionsRef, }, }; @@ -566,14 +566,16 @@ export const createComposerStore = (): ComposerStore => { // the Panel once its close animation finishes, so the last content stays mounted // (and keeps animating) until then. Identity-guarded so it no-ops when nothing changed. const finalizePanelClose = () => { - const { commands, requests } = snapshot; - if (commands.present === commands.active && requests.present === requests.active) return; + const { commands, questions } = snapshot; + if (commands.present === commands.active && questions.present === questions.active) return; snapshot = { ...snapshot, commands: commands.present === commands.active ? commands : { ...commands, present: commands.active }, - requests: - requests.present === requests.active ? requests : { ...requests, present: requests.active }, + questions: + questions.present === questions.active + ? questions + : { ...questions, present: questions.active }, }; notify(); }; @@ -583,7 +585,7 @@ export const createComposerStore = (): ComposerStore => { // revoke attachment object URLs, then restore the pristine snapshot. const reset = () => { dispatchAttachments({ type: "reset" }); - requestsMachine = INITIAL_REQUEST_STATE; + questionsMachine = INITIAL_QUESTIONS_STATE; commandSelectRef.current = null; snapshot = initialSnapshot; notify(); @@ -604,10 +606,10 @@ export const createComposerStore = (): ComposerStore => { listboxId: "", moveHighlight, setHighlight, - setRequests, + setQuestions, setDragging, resetAttachments: () => dispatchAttachments({ type: "reset" }), - activateRequests, + activateQuestions, finalizePanelClose, reset, editorRef, @@ -615,7 +617,7 @@ export const createComposerStore = (): ComposerStore => { registerEditor, containerRef, attachmentConfigRef, - submitRequestsRef, + submitAnswersRef, stopGeneratingRef, commandSelectRef, }; @@ -659,7 +661,7 @@ export const useComposerStore = ( // Subscribe to composer state from inside the tree. With a selector, the // component re-renders only when the selected value changes identity (slices // are identity-stable): -// const requests = useComposer((composer) => composer.requests); +// const questions = useComposer((composer) => composer.questions); // Without one, it returns the full snapshot and re-renders on any change. export const useComposer = ( selector?: (composer: ComposerState) => Selected, diff --git a/packages/ui/src/composer/textarea.tsx b/packages/ui/src/composer/textarea.tsx index 991e48fd..6d53b9d8 100644 --- a/packages/ui/src/composer/textarea.tsx +++ b/packages/ui/src/composer/textarea.tsx @@ -136,7 +136,7 @@ export const ComposerTextarea = ({
EngineDependencies) => { const plainText = getPlainText(nextDoc); refreshHasContent(); - // Single-select requests clear their selection once the user starts + // Single-select questions clear their selection once the user starts // typing a free-text answer. if (docChanged && plainText.trim().length > 0) { - const { requests } = store.getSnapshot(); - const currentRequest = requests.items?.[requests.step]; - if (requests.items && !currentRequest?.multiSelect) requests.clearSelections(); - requests.optionsRef.current?.clearHighlight(); + const { questions } = store.getSnapshot(); + const currentQuestion = questions.items?.[questions.step]; + if (questions.items && !currentQuestion?.multiSelect) questions.clearSelections(); + questions.optionsRef.current?.clearHighlight(); } mirrorCommandState(); @@ -434,7 +434,7 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => { { key: event.key, shiftKey: event.shiftKey }, { isCommandListOpen: commandState.isOpen, - hasActiveRequests: (store.getSnapshot().requests.items?.length ?? 0) > 0, + hasActiveQuestions: (store.getSnapshot().questions.items?.length ?? 0) > 0, isEditorEmpty: getPlainText(doc) === "", hasAttachments: store.getSnapshot().attachments.items.length > 0, submitOn: options.submitOn, @@ -476,17 +476,17 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => { } return; } - case "request-arrow": { + case "question-arrow": { // navigate() moves DOM focus onto the newly highlighted option // (roving tabindex) — never blur to . At the list boundary // (null) focus stays in the editor. event.preventDefault(); - store.getSnapshot().requests.optionsRef.current?.navigate(action.direction); + store.getSnapshot().questions.optionsRef.current?.navigate(action.direction); return; } - case "request-dismiss": { + case "question-dismiss": { event.preventDefault(); - store.getSnapshot().requests.dismissStep(); + store.getSnapshot().questions.dismissStep(); return; } case "remove-last-attachment": { @@ -529,7 +529,7 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => { }; const handleFocus = () => { - getDependencies().store.getSnapshot().requests.optionsRef.current?.clearHighlight(); + getDependencies().store.getSnapshot().questions.optionsRef.current?.clearHighlight(); if (!root) return; const ownerDocument = root.ownerDocument; ownerDocument.addEventListener("selectionchange", handleSelectionChange); diff --git a/packages/ui/src/message-utils.ts b/packages/ui/src/message-utils.ts index 57ac6498..e7228127 100644 --- a/packages/ui/src/message-utils.ts +++ b/packages/ui/src/message-utils.ts @@ -130,6 +130,6 @@ export const getFileParts = (segments: readonly MessageSegment[]): FilePart[] => .filter((s): s is MessageSegment & { type: "file" } => s.type === "file") .flatMap((s) => s.parts); -// Chain/reasoning/source extraction (and request extraction) is the consuming +// Chain/reasoning/source extraction (and question extraction) is the consuming // app's concern — grouping conventions, header formats, and dedup rules are // product policy. This package ships only the generic segmentation above. diff --git a/packages/ui/tests/a11y/ask.a11y.test.tsx b/packages/ui/tests/a11y/question.a11y.test.tsx similarity index 79% rename from packages/ui/tests/a11y/ask.a11y.test.tsx rename to packages/ui/tests/a11y/question.a11y.test.tsx index 9617fe93..ef0830e5 100644 --- a/packages/ui/tests/a11y/ask.a11y.test.tsx +++ b/packages/ui/tests/a11y/question.a11y.test.tsx @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { Ask } from "../../src/ask"; import { Composer } from "../../src/composer"; import { useComposer } from "../../src/composer/store"; -import type { ComposerRequest, ComposerSubmitData } from "../../src/composer/types"; +import type { ComposerQuestion, ComposerSubmitData } from "../../src/composer/types"; import { expectNoAxeViolations } from "./axe"; // Phase 1 interaction tests: real focus in the options group (roving @@ -11,7 +10,7 @@ import { expectNoAxeViolations } from "./axe"; // and the whole flow answerable by keyboard alone — the behaviors that // replaced the blur-to-body + document-listener model. -const SINGLE: ComposerRequest[] = [ +const SINGLE: ComposerQuestion[] = [ { id: "database", label: "Which database should we use?", @@ -19,7 +18,7 @@ const SINGLE: ComposerRequest[] = [ }, ]; -const MULTI: ComposerRequest[] = [ +const MULTI: ComposerQuestion[] = [ { id: "features", label: "Which features should we enable?", @@ -30,34 +29,34 @@ const MULTI: ComposerRequest[] = [ // Minimal headless consumer, mirroring the styled layer's wiring. const AskHarness = () => { - const requests = useComposer((composer) => composer.requests); - const request = requests.items?.[requests.step]; - if (!request) return null; - const draft = requests.drafts.get(requests.step); + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; + if (!question) return null; + const draft = questions.drafts.get(questions.step); const selected = draft?.selected ?? new Set(); return ( - - - {request.label} - - - {request.options.map((option) => ( - + + {question.label} + + + {question.options.map((option) => ( + requests.toggleOption(option.label)} + onSelect={() => questions.toggleOption(option.label)} > - {option.label} - + {option.label} + ))} - - + + ); }; -// activateRequests defers focus/listener attachment one frame past the commit +// activateQuestions defers focus/listener attachment one frame past the commit // that mounts the options. const flushFrames = async () => { await act(async () => { @@ -72,7 +71,7 @@ describe("ask a11y", () => { const submitted: ComposerSubmitData[] = []; const { container, unmount } = render( { submitted.push(data); }} @@ -105,8 +104,8 @@ describe("ask a11y", () => { fireEvent.keyDown(group, { key: "Enter" }); expect(submitted).toEqual([ { - kind: "requests", - requests: [{ id: "database", selected: ["MongoDB"] }], + kind: "answers", + answers: [{ id: "database", selected: ["MongoDB"] }], }, ]); @@ -116,7 +115,7 @@ describe("ask a11y", () => { test("multi-select checkboxes toggle aria-checked in place with Enter and Space", async () => { const { container, unmount } = render( - {}}> + {}}> , ); @@ -146,7 +145,7 @@ describe("ask a11y", () => { test("arrows still navigate after a chrome click drops focus to body", async () => { const { unmount } = render( - {}}> + {}}> , ); @@ -170,7 +169,7 @@ describe("ask a11y", () => { test("navigating past the list boundary keeps focus instead of dropping to body", async () => { const { unmount } = render( - {}}> + {}}> , ); diff --git a/packages/ui/tests/composer-escape.test.tsx b/packages/ui/tests/composer-escape.test.tsx index 16f22def..d908436c 100644 --- a/packages/ui/tests/composer-escape.test.tsx +++ b/packages/ui/tests/composer-escape.test.tsx @@ -2,10 +2,9 @@ import { describe, expect, mock, test } from "bun:test"; import { act, fireEvent, render, screen } from "@testing-library/react"; import { useState } from "react"; import { createPortal } from "react-dom"; -import { Ask } from "../src/ask"; import { Composer } from "../src/composer"; import { useComposer } from "../src/composer/store"; -import type { ComposerRequest, ComposerSubmitData } from "../src/composer/types"; +import type { ComposerQuestion, ComposerSubmitData } from "../src/composer/types"; import { Tabs } from "../src/tabs"; // Escape stops a generating composer only when pressed inside that composer. @@ -28,7 +27,7 @@ const ChatComposer = ({ const pressEscape = (target: Element | Document) => act(() => void fireEvent.keyDown(target, { key: "Escape" })); -const REQUESTS: ComposerRequest[] = [ +const REQUESTS: ComposerQuestion[] = [ { id: "database", label: "Which database?", @@ -37,25 +36,25 @@ const REQUESTS: ComposerRequest[] = [ ]; const RequestOptions = () => { - const requests = useComposer((composer) => composer.requests); - const request = requests.items?.[requests.step]; - if (!request) return null; + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; + if (!question) return null; return ( - - {request.options.map((option) => ( - + {question.options.map((option) => ( + requests.toggleOption(option.label)} + onSelect={() => questions.toggleOption(option.label)} > {option.label} - + ))} - + ); }; -// activateRequests moves focus into the options one frame past the commit. +// activateQuestions moves focus into the options one frame past the commit. const flushFrames = () => act( () => @@ -124,11 +123,11 @@ describe("Escape stops generation", () => { expect(notPrevented).toBe(true); }); - test("not while a request is open; Escape dismisses its step", async () => { + test("not while a question is open; Escape dismisses its step", async () => { const onStop = mock(); const submitted: ComposerSubmitData[] = []; render( - void submitted.push(data)}> + void submitted.push(data)}> @@ -138,7 +137,7 @@ describe("Escape stops generation", () => { pressEscape(screen.getByRole("radio", { name: "PostgreSQL" })); expect(onStop).not.toHaveBeenCalled(); - expect(submitted.at(-1)?.kind).toBe("requests"); + expect(submitted.at(-1)?.kind).toBe("answers"); }); test("not when the consumer's onKeyDown skips it", () => { diff --git a/packages/ui/tests/composer-keyboard.test.ts b/packages/ui/tests/composer-keyboard.test.ts index 565c0103..6a59c24b 100644 --- a/packages/ui/tests/composer-keyboard.test.ts +++ b/packages/ui/tests/composer-keyboard.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { interpretEditorKey, interpretRequestKey } from "../src/composer/keyboard"; +import { interpretEditorKey, interpretQuestionKey } from "../src/composer/keyboard"; const baseContext = { isCommandListOpen: false, - hasActiveRequests: false, + hasActiveQuestions: false, isEditorEmpty: false, hasAttachments: false, }; @@ -26,10 +26,10 @@ describe("interpretEditorKey", () => { }); }); - test("request arrows take over when active", () => { - const ctx = { ...baseContext, hasActiveRequests: true }; + test("question arrows take over when active", () => { + const ctx = { ...baseContext, hasActiveQuestions: true }; expect(interpretEditorKey({ key: "ArrowUp", shiftKey: false }, ctx)).toEqual({ - type: "request-arrow", + type: "question-arrow", direction: -1, }); }); @@ -55,8 +55,8 @@ describe("interpretEditorKey", () => { }); }); -describe("interpretRequestKey", () => { - const event = (key: string, extra: Partial[0]> = {}) => ({ +describe("interpretQuestionKey", () => { + const event = (key: string, extra: Partial[0]> = {}) => ({ key, ctrlKey: false, metaKey: false, @@ -66,39 +66,39 @@ describe("interpretRequestKey", () => { }); test("escape dismisses regardless of highlight", () => { - expect(interpretRequestKey(event("Escape"), { hasHighlight: false })).toEqual({ + expect(interpretQuestionKey(event("Escape"), { hasHighlight: false })).toEqual({ type: "dismiss-step", }); }); test("without highlight, other keys pass through", () => { - expect(interpretRequestKey(event("ArrowDown"), { hasHighlight: false })).toBeNull(); + expect(interpretQuestionKey(event("ArrowDown"), { hasHighlight: false })).toBeNull(); }); test("with highlight, arrows navigate and enter selects", () => { - expect(interpretRequestKey(event("ArrowDown"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("ArrowDown"), { hasHighlight: true })).toEqual({ type: "navigate-options", direction: 1, }); - expect(interpretRequestKey(event("Enter"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("Enter"), { hasHighlight: true })).toEqual({ type: "select-option", }); - expect(interpretRequestKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({ type: "go-back", }); }); test("printable characters route back to the input", () => { - expect(interpretRequestKey(event("x"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("x"), { hasHighlight: true })).toEqual({ type: "insert-character", character: "x", }); - expect(interpretRequestKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull(); + expect(interpretQuestionKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull(); }); test("defaultPrevented events are ignored", () => { expect( - interpretRequestKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }), + interpretQuestionKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }), ).toBeNull(); }); }); @@ -142,10 +142,10 @@ describe("interpretEditorKey: submitOn", () => { }); }); -describe("interpretRequestKey: Space selects", () => { +describe("interpretQuestionKey: Space selects", () => { test("Space toggles the highlighted option (APG radio/checkbox)", () => { expect( - interpretRequestKey( + interpretQuestionKey( { key: " ", ctrlKey: false, metaKey: false, altKey: false, defaultPrevented: false }, { hasHighlight: true }, ), @@ -153,21 +153,21 @@ describe("interpretRequestKey: Space selects", () => { }); }); -describe("interpretEditorKey: request dismiss", () => { +describe("interpretEditorKey: question dismiss", () => { test("editor-focused Escape dismisses the active question", () => { expect( interpretEditorKey( { key: "Escape", shiftKey: false }, - { ...baseContext, hasActiveRequests: true }, + { ...baseContext, hasActiveQuestions: true }, ), - ).toEqual({ type: "request-dismiss" }); + ).toEqual({ type: "question-dismiss" }); }); test("command list still owns Escape when both are active", () => { expect( interpretEditorKey( { key: "Escape", shiftKey: false }, - { ...baseContext, hasActiveRequests: true, isCommandListOpen: true }, + { ...baseContext, hasActiveQuestions: true, isCommandListOpen: true }, ), ).toEqual({ type: "command-close" }); }); diff --git a/packages/ui/tests/composer-request-machine.test.ts b/packages/ui/tests/composer-question-machine.test.ts similarity index 62% rename from packages/ui/tests/composer-request-machine.test.ts rename to packages/ui/tests/composer-question-machine.test.ts index 5c570f7b..b61e81e1 100644 --- a/packages/ui/tests/composer-request-machine.test.ts +++ b/packages/ui/tests/composer-question-machine.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from "bun:test"; import { - compileRequests, - INITIAL_REQUEST_STATE, + compileAnswers, + INITIAL_QUESTIONS_STATE, isLastStep, - type RequestState, - transitionRequests, -} from "../src/composer/request-machine"; -import type { ComposerRequest } from "../src/composer/types"; + type QuestionsState, + transitionQuestions, +} from "../src/composer/question-machine"; +import type { ComposerQuestion } from "../src/composer/types"; -const requests: ComposerRequest[] = [ +const questions: ComposerQuestion[] = [ { id: "q1", label: "Q1?", options: [{ label: "A", description: "a" }] }, { id: "q2", @@ -21,9 +21,9 @@ const requests: ComposerRequest[] = [ }, ]; -describe("transitionRequests", () => { +describe("transitionQuestions", () => { test("select-option on single-select advances a step and clears input", () => { - const { next, effects } = transitionRequests(INITIAL_REQUEST_STATE, requests, { + const { next, effects } = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, { type: "select-option", label: "A", }); @@ -34,8 +34,8 @@ describe("transitionRequests", () => { }); test("select-option on the last step compiles and submits", () => { - const onLast: RequestState = { step: 1, drafts: new Map() }; - const { next, effects } = transitionRequests(onLast, requests, { + const onLast: QuestionsState = { step: 1, drafts: new Map() }; + const { next, effects } = transitionQuestions(onLast, questions, { type: "select-option", label: "B", }); @@ -46,21 +46,21 @@ describe("transitionRequests", () => { expect(next.drafts.get(1)?.selected.has("B")).toBe(true); }); - test("continue-step on the last step emits submit-requests with compiled payload", () => { - const state: RequestState = { + test("continue-step on the last step emits submit-questions with compiled payload", () => { + const state: QuestionsState = { step: 1, drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]), }; - const withToggle = transitionRequests(state, requests, { type: "toggle-option", label: "B" }); - const { effects } = transitionRequests(withToggle.next, requests, { + const withToggle = transitionQuestions(state, questions, { type: "toggle-option", label: "B" }); + const { effects } = transitionQuestions(withToggle.next, questions, { type: "continue-step", freeText: "extra context", }); - const submit = effects.find((e) => e.type === "submit-requests"); + const submit = effects.find((e) => e.type === "submit-answers"); expect(submit).toBeDefined(); - if (submit?.type !== "submit-requests") throw new Error("unreachable"); - expect(submit.requests).toEqual([ + if (submit?.type !== "submit-answers") throw new Error("unreachable"); + expect(submit.answers).toEqual([ { id: "q1", selected: ["A"] }, { id: "q2", selected: ["B"], text: "extra context" }, ]); @@ -68,16 +68,19 @@ describe("transitionRequests", () => { }); test("multiSelect toggle flips membership and keeps free text", () => { - const first = transitionRequests({ step: 1, drafts: new Map() }, requests, { + const first = transitionQuestions({ step: 1, drafts: new Map() }, questions, { + type: "toggle-option", + label: "B", + }); + const second = transitionQuestions(first.next, questions, { type: "toggle-option", label: "B", }); - const second = transitionRequests(first.next, requests, { type: "toggle-option", label: "B" }); expect(second.next.drafts.get(1)?.selected.size).toBe(0); }); test("dismiss-step skips the draft entirely", () => { - const { next } = transitionRequests(INITIAL_REQUEST_STATE, requests, { + const { next } = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, { type: "dismiss-step", }); expect(next.step).toBe(1); @@ -85,11 +88,11 @@ describe("transitionRequests", () => { }); test("step-back preserves in-progress free text and restores the target's", () => { - const state: RequestState = { + const state: QuestionsState = { step: 1, drafts: new Map([[0, { selected: new Set(), freeText: "saved" }]]), }; - const { next, effects } = transitionRequests(state, requests, { + const { next, effects } = transitionQuestions(state, questions, { type: "step-back", currentText: "typing on q2", }); @@ -100,43 +103,43 @@ describe("transitionRequests", () => { }); test("step navigation clamps at the ends", () => { - const atStart = transitionRequests(INITIAL_REQUEST_STATE, requests, { + const atStart = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, { type: "step-back", currentText: "", }); - expect(atStart.next).toBe(INITIAL_REQUEST_STATE); + expect(atStart.next).toBe(INITIAL_QUESTIONS_STATE); expect(atStart.effects).toEqual([]); }); }); -describe("compileRequests", () => { - test("skipped requests compile to empty-selection entries keyed by id", () => { - expect(compileRequests(INITIAL_REQUEST_STATE, requests)).toEqual([ +describe("compileAnswers", () => { + test("skipped questions compile to empty-selection entries keyed by id", () => { + expect(compileAnswers(INITIAL_QUESTIONS_STATE, questions)).toEqual([ { id: "q1", selected: [] }, { id: "q2", selected: [] }, ]); }); test("free text on single-select compiles to a text entry", () => { - const state: RequestState = { + const state: QuestionsState = { step: 0, drafts: new Map([[0, { selected: new Set(), freeText: " custom " }]]), }; - expect(compileRequests(state, requests)[0]).toEqual({ id: "q1", selected: [], text: "custom" }); + expect(compileAnswers(state, questions)[0]).toEqual({ id: "q1", selected: [], text: "custom" }); }); test("text is omitted entirely when the user typed nothing", () => { - const state: RequestState = { + const state: QuestionsState = { step: 0, drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]), }; - const entry = compileRequests(state, requests)[0]; + const entry = compileAnswers(state, questions)[0]; expect(entry).toBeDefined(); expect(entry && "text" in entry).toBe(false); }); test("option values are echoed when present, labels as fallback", () => { - const valued: ComposerRequest[] = [ + const valued: ComposerQuestion[] = [ { id: "framework", label: "Which framework?", @@ -145,18 +148,18 @@ describe("compileRequests", () => { }, ]; // Toggle keys are labels (how the widget wires Option value today). - const state: RequestState = { + const state: QuestionsState = { step: 0, drafts: new Map([[0, { selected: new Set(["Next.js", "Remix"]), freeText: "" }]]), }; - expect(compileRequests(state, valued)[0]).toEqual({ + expect(compileAnswers(state, valued)[0]).toEqual({ id: "framework", selected: ["next", "Remix"], }); }); test("a label colliding with another option's value resolves to the selected option", () => { - const colliding: ComposerRequest[] = [ + const colliding: ComposerQuestion[] = [ { id: "pick", label: "Pick one", @@ -169,28 +172,28 @@ describe("compileRequests", () => { // The widget stores an option's identity (value ?? label) — here, "b" for // the second option. A cross-field lookup would match the first option's // label instead and wrongly emit "c". - const state: RequestState = { + const state: QuestionsState = { step: 0, drafts: new Map([[0, { selected: new Set(["b"]), freeText: "" }]]), }; - expect(compileRequests(state, colliding)[0]).toEqual({ id: "pick", selected: ["b"] }); + expect(compileAnswers(state, colliding)[0]).toEqual({ id: "pick", selected: ["b"] }); }); test("keys already stored as values resolve to the same values", () => { - const valued: ComposerRequest[] = [ + const valued: ComposerQuestion[] = [ { id: "db", label: "Database?", options: [{ value: "pg", label: "PostgreSQL" }] }, ]; - const state: RequestState = { + const state: QuestionsState = { step: 0, drafts: new Map([[0, { selected: new Set(["pg"]), freeText: "" }]]), }; - expect(compileRequests(state, valued)[0]).toEqual({ id: "db", selected: ["pg"] }); + expect(compileAnswers(state, valued)[0]).toEqual({ id: "db", selected: ["pg"] }); }); }); describe("isLastStep", () => { - test("reports the final request", () => { - expect(isLastStep({ step: 0, drafts: new Map() }, requests)).toBe(false); - expect(isLastStep({ step: 1, drafts: new Map() }, requests)).toBe(true); + test("reports the final question", () => { + expect(isLastStep({ step: 0, drafts: new Map() }, questions)).toBe(false); + expect(isLastStep({ step: 1, drafts: new Map() }, questions)).toBe(true); }); }); diff --git a/packages/ui/tests/composer-scroll-containment.test.tsx b/packages/ui/tests/composer-scroll-containment.test.tsx index 59069a1d..765896c2 100644 --- a/packages/ui/tests/composer-scroll-containment.test.tsx +++ b/packages/ui/tests/composer-scroll-containment.test.tsx @@ -1,14 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { act, fireEvent, render, screen } from "@testing-library/react"; -import { Ask } from "../src/ask"; import { Composer } from "../src/composer"; import { scrollCaretIntoView } from "../src/composer/editor-dom"; import { useComposer } from "../src/composer/store"; -import type { ComposerRequest } from "../src/composer/types"; +import type { ComposerQuestion } from "../src/composer/types"; import { scrollIntoViewWithin } from "../src/internal/scroll-into-view-within"; // A composer embedded in a scrolling page scrolls only its own scroll areas: -// programmatic writes and the request flow's focus moves must leave the page +// programmatic writes and the question flow's focus moves must leave the page // where it is (CHAT-42). // happy-dom has no layout, so each element gets a stubbed box. @@ -175,10 +174,10 @@ describe("scrollIntoViewWithin", () => { }); // --------------------------------------------------------------------------- -// The request flow's own focus moves +// The question flow's own focus moves // --------------------------------------------------------------------------- -const REQUESTS: ComposerRequest[] = [ +const REQUESTS: ComposerQuestion[] = [ { id: "database", label: "Which database?", @@ -187,27 +186,27 @@ const REQUESTS: ComposerRequest[] = [ ]; const RequestOptions = () => { - const requests = useComposer((composer) => composer.requests); - const request = requests.items?.[requests.step]; - if (!request) return null; + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; + if (!question) return null; return ( - - - {request.options.map((option) => ( - + + {question.options.map((option) => ( + requests.toggleOption(option.label)} + onSelect={() => questions.toggleOption(option.label)} > {option.label} - + ))} - - + + ); }; -// activateRequests moves focus into the options one frame past the commit. +// activateQuestions moves focus into the options one frame past the commit. const flushFrames = () => act( () => @@ -216,7 +215,7 @@ const flushFrames = () => }), ); -describe("request focus moves keep the page in place", () => { +describe("question focus moves keep the page in place", () => { const focus = HTMLElement.prototype.focus; let calls: { element: HTMLElement; options?: FocusOptions }[] = []; @@ -234,7 +233,7 @@ describe("request focus moves keep the page in place", () => { const renderQuestion = async (container?: HTMLElement) => { render( - {}}> + {}}> , diff --git a/packages/ui/tests/composer-store-present.test.ts b/packages/ui/tests/composer-store-present.test.ts index 289606dd..d83b55e6 100644 --- a/packages/ui/tests/composer-store-present.test.ts +++ b/packages/ui/tests/composer-store-present.test.ts @@ -33,18 +33,18 @@ describe("composer store: sticky panel presence", () => { expect(store.getSnapshot().commands.present).toBe(false); }); - test("request presence is sticky the same way", () => { + test("question presence is sticky the same way", () => { const store = createComposerStore(); - store.setRequests([{ id: "pick", label: "Pick one", options: [{ label: "A" }] }]); - expect(store.getSnapshot().requests.active).toBe(true); - expect(store.getSnapshot().requests.present).toBe(true); + store.setQuestions([{ id: "pick", label: "Pick one", options: [{ label: "A" }] }]); + expect(store.getSnapshot().questions.active).toBe(true); + expect(store.getSnapshot().questions.present).toBe(true); - store.setRequests(null); - expect(store.getSnapshot().requests.active).toBe(false); - expect(store.getSnapshot().requests.present).toBe(true); + store.setQuestions(null); + expect(store.getSnapshot().questions.active).toBe(false); + expect(store.getSnapshot().questions.present).toBe(true); store.finalizePanelClose(); - expect(store.getSnapshot().requests.present).toBe(false); + expect(store.getSnapshot().questions.present).toBe(false); }); test("reset clears presence", () => { diff --git a/packages/ui/tests/message-utils.test.ts b/packages/ui/tests/message-utils.test.ts index 017598e7..7baae08b 100644 --- a/packages/ui/tests/message-utils.test.ts +++ b/packages/ui/tests/message-utils.test.ts @@ -50,6 +50,6 @@ describe("groupTurns", () => { }); }); -// getAskUserInfo, getSourcesInfo, splitReasoningByHeaders, getChainInfo, and +// getQuestionsInfo, getSourcesInfo, splitReasoningByHeaders, getChainInfo, and // getReasoningInfo moved to the app (lib/ai/steps-info.ts, lib/ai/message-info.ts) // — tool contracts, header conventions, and dedup rules are the consumer's. diff --git a/tools/ask-user.ts b/tools/ask-user.ts index cc429b11..a64c17e4 100644 --- a/tools/ask-user.ts +++ b/tools/ask-user.ts @@ -25,7 +25,7 @@ const questionSchema = z.object({ ), }); -const askUserInputSchema = z.object({ +const questionsInputSchema = z.object({ questions: z .array(questionSchema) .min(1) @@ -43,7 +43,7 @@ export const askUser = tool({ - You want to confirm a potentially destructive or irreversible action Provide 1-4 questions, each with 2-5 concrete options with short labels and helpful descriptions. Users can also type a custom answer for any question.`, - inputSchema: askUserInputSchema, + inputSchema: questionsInputSchema, // The chat answers with addToolOutput: a JSON object of question → answer. outputSchema: z.string(), }); From e4e90dfb49a0645616496f7f5353a4623efb9f11 Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Fri, 23 Oct 2026 21:26:14 +0300 Subject: [PATCH 4/5] style: reformat an import after the rename --- content/docs/primitives/composer/demos/question-flow.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/content/docs/primitives/composer/demos/question-flow.tsx b/content/docs/primitives/composer/demos/question-flow.tsx index 962729ae..ec37ec7f 100644 --- a/content/docs/primitives/composer/demos/question-flow.tsx +++ b/content/docs/primitives/composer/demos/question-flow.tsx @@ -1,10 +1,6 @@ "use client"; -import { - Composer, - type ComposerQuestion, - type ComposerSubmitData, -} from "@intentface/ui/composer"; +import { Composer, type ComposerQuestion, type ComposerSubmitData } from "@intentface/ui/composer"; import { ArrowUp } from "@keyline-icons/react"; import { useState } from "react"; import { Controls, Prompt } from "./question"; From a2f828a5a2ed86c1ed22c5a766d589f9f3944c2b Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Fri, 23 Oct 2026 22:19:06 +0300 Subject: [PATCH 5/5] docs(composer): document the Question parts --- content/docs/primitives/composer.mdx | 138 ++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/content/docs/primitives/composer.mdx b/content/docs/primitives/composer.mdx index 9168ed51..6f093867 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -32,7 +32,7 @@ The bare nesting — every part is optional except `Composer` and `Container`: {(composer) => - composer.commands.active && ( + composer.commands.active ? ( @@ -44,7 +44,27 @@ The bare nesting — every part is optional except `Composer` and `Container`: )} - ) + ) : composer.questions.active ? ( + + + + + + + + + + + + + + + + + + + + ) : null } @@ -575,6 +595,120 @@ on `questions.active`. drives it. Neither ships copy — supply the labels as children, and read `questions.isLastStep` to switch the continue wording. +### Composer.QuestionHeader + +Row container for `Composer.QuestionLabel` and an optional +`Composer.QuestionNavigation`. Renders `data-composer-question-header`. No +part-specific props or state attributes. + +### Composer.QuestionLabel + +The question text. Renders `data-composer-question-label` and carries the id +that `Composer.QuestionOptions` uses as its accessible name, so the group is +labelled with no wiring. No part-specific props or state attributes. + +### Composer.QuestionNavigation + +Row container for `Composer.QuestionPrevious`, `Composer.QuestionStepLabel` and +`Composer.QuestionNext`. A single question needs none of them. Renders +`data-composer-question-navigation`. No part-specific props or state attributes. + +### Composer.QuestionPrevious + +Button that moves to the previous step. Wire `onClick` to `questions.goBack` and +disable it on the first step. Its default accessible name is "Previous +question"; override it with `aria-label`. Renders +`data-composer-question-previous`. No part-specific props or state attributes. + +### Composer.QuestionStepLabel + +The step indicator. It reads the current step and the total from the composer, +and you supply the text, because there is no default format. Renders +`data-composer-question-step-label` and carries the id that +`Composer.QuestionOptions` uses as its description. + +export const questionStepLabelProps = [ + { name: "children", type: "ReactNode | ((state: { current: number; total: number }) => ReactNode)", description: "The step text, or a function of the step and the total. current counts from 1." }, +]; + + + +### Composer.QuestionNext + +Button that moves to the next step. Wire `onClick` to `questions.goNext` and +disable it on the last step. Its default accessible name is "Next question"; +override it with `aria-label`. Renders `data-composer-question-next`. No +part-specific props or state attributes. + +### Composer.QuestionOptions + +Fieldset wrapper for `Composer.QuestionOption` items. It gives its options +`multiSelect`, `groupName` and the highlight, and each option registers itself +on mount. It renders as a `radiogroup`, or a `group` when `multiSelect` is set, +labelled by `Composer.QuestionLabel` and described by +`Composer.QuestionStepLabel`. + +export const questionOptionsProps = [ + { name: "multiSelect", type: "boolean", default: "false", description: "Options act as checkboxes instead of radios." }, + { name: "groupName", type: "string", default: "\"\"", description: "Shared name for every option in the group." }, + { name: "ref", type: "RefObject", description: "Keyboard-navigation handle. Pass questions.optionsRef so the composer's question keys reach the options." }, +]; + + + +export const questionOptionsAttrs = [ + { attribute: "data-composer-question-options", description: "The fieldset element." }, +]; + + + +### Composer.QuestionOption + +A selectable option. It registers itself with `Composer.QuestionOptions` on +mount and takes its highlight from there. The option is the control itself: it +carries the `radio` or `checkbox` role and `aria-checked`, and the highlighted +option is the group's only tab stop. + +export const questionOptionProps = [ + { name: "value", type: "string", default: "\"\"", description: "The option's identity within the group, used for the highlight." }, + { name: "selected", type: "boolean", default: "false", description: "Whether the option is selected. Read it from the step's draft." }, + { name: "onSelect", type: "() => void", description: "Called when the option is clicked. Wire it to questions.toggleOption(value)." }, +]; + + + +export const questionOptionAttrs = [ + { attribute: "data-composer-question-option", description: "The option element." }, + { attribute: "data-highlighted", description: "Present while the option is highlighted by keyboard or hover." }, + { attribute: "data-selected", description: "Present while the option is selected." }, +]; + + + +### Composer.QuestionOptionContent + +Column wrapper for `Composer.QuestionOptionLabel` and +`Composer.QuestionOptionDescription`. Renders +`data-composer-question-option-content`. No part-specific props or state +attributes. + +### Composer.QuestionOptionLabel + +The option's title text. Renders `data-composer-question-option-label`. No +part-specific props or state attributes. + +### Composer.QuestionOptionDescription + +The option's description text. Renders +`data-composer-question-option-description`. No part-specific props or state +attributes. + +### Composer.QuestionHints + +Keyboard shortcut hints shown below the options. It ships no copy, so supply +the hints as children. Renders `data-composer-question-hints`. No part-specific +props or state attributes. + ### Hooks export const hooks = [