diff --git a/.changeset/generic-requests.md b/.changeset/generic-requests.md new file mode 100644 index 00000000..128d4056 --- /dev/null +++ b/.changeset/generic-requests.md @@ -0,0 +1,10 @@ +--- +"@intentface/ui": minor +--- + +- [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 33b2f330..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`, `AskUser`. +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 c5c04105..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, - AskUser, AskUserHints, AskUserDismiss, AskUserContinue, + 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; - questions?: AskUserQuestion[]; // present → drives the ask-user flow + questions?: ComposerQuestion[]; // present → drives the question flow defaultValue?: ComposerSnapshot; // uncontrolled editor doc value?: ComposerSnapshot; // controlled editor doc onValueChange?: (snapshot: ComposerSnapshot) => void; @@ -138,7 +138,7 @@ type ComposerMessageSubmit = { type ComposerAnswersSubmit = { kind: "answers"; - answers: ComposerAnswerEntry[]; // emitted by the ask-user flow + answers: ComposerAnswer[]; // emitted by the question flow }; ``` @@ -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, 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, … }` | -| `askUser` | `{ questions, step, answers, 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. -### Ask-user — `Composer.AskUser` / `AskUserHints` / `AskUserDismiss` / `AskUserContinue` +### Questions — `Composer.Question` / `Composer.QuestionHints` / `Composer.QuestionDismiss` / `Composer.QuestionContinue` -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 `questions` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `questions` 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.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 ask-user 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 `questions` is active and the user is mid-flow → `askUser.continueStep()` advances or finalises (emitting `{ kind: "answers" }`). +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 8e009a48..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 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 questions. It's a single component (`components/ai/composer.tsx`) exposed as a compound API via `Composer.X` parts. Import from `@/components/ai/composer`. @@ -44,10 +44,10 @@ type ComposerSubmitData = } | { kind: "answers"; - answers: ComposerAnswerEntry[]; // produced by the questionnaire flow + answers: ComposerAnswer[]; // produced by the question flow }; -type ComposerAnswerEntry = +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). | -| `questions` | `AskUserQuestion[]` | When present, the composer enters questionnaire mode. See [Questionnaire](#questionnaire). | +| `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 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 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: questionnaire +### Add-on: questions -Pass `questions` to switch the composer into structured-question mode. 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 `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.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 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: "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 askUser = useComposer((composer) => composer.askUser); // questionnaire 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`, `ComposerAnswersSubmit`, `ComposerAnswerEntry`, `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/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/composer.tsx b/components/ai/composer.tsx index c99d0360..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 { AskUser } from "@/components/ai/ask-user"; import { Attachments } from "@/components/ai/attachments"; import { Chip } from "@/components/ai/chip"; import Button from "@/components/ui/button"; @@ -47,7 +48,7 @@ export type { AttachmentsApi, ChipData, CommandItemKind, - ComposerAnswerEntry, + ComposerAnswer, ComposerAnswersSubmit, ComposerCommandsConfig, ComposerCommandsItems, @@ -55,6 +56,8 @@ export type { ComposerEditorHandle, ComposerEditorState, ComposerMessageSubmit, + ComposerQuestion, + ComposerQuestionOption, ComposerSnapshot, ComposerSubmitData, ComposerSubmitOn, @@ -586,78 +589,169 @@ const ComposerCommands = ({ prefix }: ComposerCommandsProps) => ( ); // --------------------------------------------------------------------------- -// AskUser (with sub-Parts) — default render for the ask-user flow registered -// via the `questions` prop on Composer Root. +// Question — default render for the question flow armed by the `questions` +// prop on Composer Root. // --------------------------------------------------------------------------- -const ComposerAskUser = () => { - const askUser = useComposer((composer) => composer.askUser); +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 ( + + ); +}; + +const ComposerQuestion = () => { + const questions = useComposer((composer) => composer.questions); - // Content only. The consumer gates the enclosing Panel on `askUser.active`; - // this renders the question compound, or null when there are none. `display` + // 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 = askUser.questions?.[askUser.step] ?? null; + 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 = askUser.answers.get(askUser.step) ?? { + const draft = questions.drafts.get(questions.step) ?? { selected: new Set(), freeText: "", }; - const totalQuestions = askUser.questions?.length ?? 0; + const totalQuestions = questions.items?.length ?? 0; return ( - - - {display.question} - {!askUser.isSingle && totalQuestions > 1 && ( - - - + + + + {display.label} + + {!questions.isSingle && totalQuestions > 1 && ( + + + + + {({ current, total }) => `${current} of ${total} questions`} - - - + + + + + )} - + {display.options && ( - - {display.options.map((option) => ( - askUser.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 answer carries. + const optionValue = option.value ?? option.label; + return ( + 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.description && ( + + {option.description} + + )} + + + ); + })} + )} - + ); }; -const ComposerAskUserHints = ({ className, ...props }: ComponentProps) => { - const askUser = useComposer((composer) => composer.askUser); - const totalQuestions = askUser.questions?.length ?? 0; +const ComposerQuestionHints = ({ + className, + ...props +}: ComponentProps) => { + const questions = useComposer((composer) => composer.questions); + const totalQuestions = questions.items?.length ?? 0; return ( - + ↑ ↓ navigate @@ -665,7 +759,7 @@ const ComposerAskUserHints = ({ className, ...props }: ComponentProps ↵ select - {!askUser.isSingle && totalQuestions > 1 && ( + {!questions.isSingle && totalQuestions > 1 && ( ← → between questions @@ -674,21 +768,21 @@ const ComposerAskUserHints = ({ className, ...props }: ComponentProps esc skip - + ); }; -type ComposerAskUserDismissProps = ComponentProps; +type ComposerQuestionDismissProps = ComponentProps; -const ComposerAskUserDismiss = ({ className, ...props }: ComposerAskUserDismissProps) => { - const askUser = useComposer((composer) => composer.askUser); +const ComposerQuestionDismiss = ({ className, ...props }: ComposerQuestionDismissProps) => { + const questions = useComposer((composer) => composer.questions); return ( ); }; @@ -729,10 +823,10 @@ export const Composer = Object.assign(ComposerRoot, { Panel: ComposerPanel, Popover: ComposerPopover, Textarea: ComposerTextarea, - AskUser: ComposerAskUser, - AskUserHints: ComposerAskUserHints, - AskUserDismiss: ComposerAskUserDismiss, - AskUserContinue: ComposerAskUserContinue, + 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 e0f719cf..5edc945c 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -41,6 +41,7 @@ import { type CommandItemData, Composer, type ComposerCommandsMap, + type ComposerQuestion, type ComposerSubmitData, } from "@/components/ai/composer"; import { GeneratedUi } from "@/components/ai/generated-ui"; @@ -62,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"; @@ -222,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 ( @@ -250,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; }) => { @@ -391,7 +392,7 @@ const InterleavedSteps = ({ if (seg.type === "tool") { return seg.parts.map((part, j) => part.type === "tool-askUser" ? ( - + ) : ( ), @@ -432,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; @@ -474,7 +475,7 @@ const ChatMessageItem = memo( )} @@ -522,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") @@ -683,7 +684,7 @@ const useAskUserPanelState = ( const next = part ? { - type: "ask-user" as const, + type: "questions" as const, toolCallId: part.toolCallId, questions: part.input.questions, } @@ -701,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 = { @@ -744,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); @@ -828,30 +829,46 @@ 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 — setQuestions resets the machine + // on any array identity change, so a fresh .map() per render would wipe + // in-progress drafts. + const activeQuestions = demoQuestions ?? toolQuestions; + const composerQuestions = useMemo( + () => + activeQuestions?.map( + (question, index): ComposerQuestion => ({ + id: `q-${index}`, + label: question.question, + options: question.options, + multiSelect: question.multiSelect, + }), + ), + [activeQuestions], + ); const handleSubmit = useCallback( async (data: ComposerSubmitData) => { if (data.kind === "answers") { // 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). + if (panelState.type !== "questions") return; + // Project the flat entries back into the question→answer map the tool + // output is read as (getQuestionsInfo / getQuestionsStepInfo). Ids resolve + // through the minted questions; 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; - } + if (entry.selected.length === 0 && entry.text === undefined) continue; + const label = composerQuestions?.find((question) => question.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, + composerQuestions, toolValues, selections, clearSelections, @@ -925,15 +943,15 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { onSubmit={handleSubmit} isSubmitting={isSending} commands={composerCommands} - questions={demoQuestions ?? askUserQuestions ?? undefined} + questions={composerQuestions} > {(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 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 ( <> @@ -943,7 +961,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { ); } - if (composer.askUser.active) return ; + if (composer.questions.active) return ; return null; }} @@ -990,7 +1008,7 @@ const ChatInputInner = memo(({ panelState, status }: ChatInputInnerProps) => { { } /> - {isAskUser ? ( + {hasQuestions ? ( - - + + ) : ( 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..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 ask-user + `: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 -ask-user 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 c8f0ddaa..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-user-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`, `AskUser.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 4a141482..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, ask-user 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-user` | Parts for the questions a model asks the user | | `/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..6f093867 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 questions for the user, from clarifying questions to 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 { QuestionFlow } from './composer/demos/question-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 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. @@ -31,7 +32,7 @@ The bare nesting — every part is optional except `Composer` and `Container`: {(composer) => - composer.commands.active && ( + composer.commands.active ? ( @@ -43,7 +44,27 @@ The bare nesting — every part is optional except `Composer` and `Container`: )} - ) + ) : composer.questions.active ? ( + + + + + + + + + + + + + + + + + + + + ) : null } @@ -59,7 +80,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`, `questions.active`, …) and the panel opens and closes to match — priority is the order of your branches. ## Examples @@ -82,17 +103,25 @@ or the other; the content is identical. } file="primitives/composer/demos/popover.tsx" /> -### Ask-user flow +### Question 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 `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: "answers" }`. Passing a -fresh `questions` 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-user-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 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" /> ### Attachments @@ -147,7 +176,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 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. @@ -156,7 +185,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 question flow being active, takes priority over normal typing. **Normal typing** @@ -179,9 +208,9 @@ export const commandKeys = [ -**Ask-user active** +**Question 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 +218,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 +244,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 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: "questions", type: "AskUserQuestion[]", description: "When set, arms the ask-user flow (blurs the editor, flips askUser.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." }, @@ -272,7 +301,7 @@ export const Chat = () => { ```ts type ComposerSubmitData = | { kind: "message"; text: string; files: AttachmentItem[] } - | { kind: "answers"; answers: ComposerAnswerEntry[] }; + | { kind: "answers"; answers: ComposerAnswer[] }; ``` `files` are the generic attachment descriptors — your `onSubmit` adapts them to @@ -401,7 +430,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 ? : 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 @@ -553,18 +582,132 @@ still flows top-to-bottom — and calls the callback once per group with ``` -### Ask-user +### Composer.Question -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 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`. -`AskUser.Dismiss` is a plain button you wire to `askUser.dismissStep`; -`AskUser.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 -`askUser.isLastStep` to switch the continue wording. +`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 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..95cba283 --- /dev/null +++ b/content/docs/primitives/composer/demos/approval-flow.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { + Composer, + 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 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 +// question 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 (ComposerQuestionOption & { command?: string })[]; + +const QUESTIONS: ComposerQuestion[] = [ + { + 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 !== "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.answers.find((answer) => answer.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 question: no Navigation, no StepLabel — the label, the command being +// approved (free-form children inside Composer.Question), and numbered options. +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); + + return ( + + + + Do you want to allow me to run{" "} + + {COMMAND} + {" "} + for this workspace? + + +
+ {COMMAND} +
+ + {OPTIONS.map((option, index) => { + const optionValue = option.value ?? option.label; + const selected = Boolean(draft?.selected.has(optionValue)); + return ( + 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]" + > + + + + {option.label} + {"command" in option && ( + <> + {" "} + + {option.command} + + + )} + + + {/* Enter selects the highlighted row — surface that on it. */} + + + ); + })} + +
+ ); +}; + +const SendIcon = (props: ComponentProps<"svg">) => ( + +); + +const Controls = () => { + const questions = useComposer((composer) => composer.questions); + + return ( + <> + + Skip + + + Submit + + + ); +}; diff --git a/content/docs/primitives/composer/demos/ask-user.tsx b/content/docs/primitives/composer/demos/ask-user.tsx deleted file mode 100644 index cdc01108..00000000 --- a/content/docs/primitives/composer/demos/ask-user.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -import { AskUser } from "@intentface/ui/ask-user"; -import { useComposer } from "@intentface/ui/composer"; - -// The parts are structural; the current question and selections come from the -// composer's askUser slice. -export const Prompt = () => { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; - - if (!question) return null; - - const entry = askUser.answers.get(askUser.step); - const total = askUser.questions?.length ?? 0; - - return ( - - - - {question.question} - - {!askUser.isSingle && total > 1 && ( - - - ‹ - - - {({ current, total: count }) => `${current} of ${count}`} - - - › - - - )} - - {question.options && ( - - {question.options.map((option) => { - const selected = Boolean(entry?.selected.has(option.label)); - return ( - askUser.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. */} - - - - {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); - - return ( - <> - - Skip - - - {askUser.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-user-flow.tsx b/content/docs/primitives/composer/demos/question-flow.tsx similarity index 73% rename from content/docs/primitives/composer/demos/ask-user-flow.tsx rename to content/docs/primitives/composer/demos/question-flow.tsx index 7f7641a9..ec37ec7f 100644 --- a/content/docs/primitives/composer/demos/ask-user-flow.tsx +++ b/content/docs/primitives/composer/demos/question-flow.tsx @@ -1,42 +1,46 @@ "use client"; -import { type AskUserQuestion, Composer, 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-user"; +import { Controls, Prompt } from "./question"; -// Setting `questions` arms the flow and flips askUser.active. Answering or -// skipping the last one fires onSubmit with { kind: "answers" }. -const QUESTIONS: AskUserQuestion[] = [ +// 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 QUESTIONS: ComposerQuestion[] = [ { - 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 QuestionFlow = () => { + const [questions, setQuestions] = useState(QUESTIONS); const [done, setDone] = useState(false); const handleSubmit = (data: ComposerSubmitData) => { 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/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/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 6b7f7f11..911aaeb6 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"; @@ -34,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. @@ -65,8 +63,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 `questions` prop: ComposerQuestion +// requires a consumer-minted `id`, so tool questions are mapped explicitly at +// 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 a4309d71..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-user": "./packages/ui/src/ask-user/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..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, 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, 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-user` | 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 67703249..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-user": { - "types": "./dist/ask-user/index.d.ts", - "default": "./dist/ask-user/index.js" - }, "./message": { "types": "./dist/message/index.d.ts", "default": "./dist/message/index.js" 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/composer/actions.tsx b/packages/ui/src/composer/actions.tsx index 952acba2..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.askUser.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 fec66f18..d18d78de 100644 --- a/packages/ui/src/composer/index.ts +++ b/packages/ui/src/composer/index.ts @@ -12,12 +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 { interpretAskUserKey, interpretEditorKey } 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 { + 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 { @@ -28,30 +50,30 @@ export { spliceSegments, } from "./segments"; export type { - ComposerAskUserState, ComposerAttachmentsState, ComposerCommandsState, ComposerEditorState, ComposerPanelSlice, + ComposerQuestionsState, ComposerState, ComposerStore, } from "./store"; export { useComposer, useComposerStore } from "./store"; export type { ComposerTextareaProps, ComposerTextareaState } from "./textarea"; export type { - AskUserOption, - AskUserQuestion, AttachmentsApi, ChipData, CommandItemData, CommandItemKind, - ComposerAnswerEntry, + ComposerAnswer, ComposerAnswersSubmit, ComposerCommandsConfig, ComposerCommandsItems, ComposerCommandsMap, ComposerEditorHandle, ComposerMessageSubmit, + ComposerQuestion, + ComposerQuestionOption, ComposerSnapshot, ComposerSubmitData, PrefixOnSelectContext, diff --git a/packages/ui/src/composer/keyboard.ts b/packages/ui/src/composer/keyboard.ts index 62e4e7ff..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: "ask-user-arrow"; direction: 1 | -1 } - | { type: "ask-user-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; - hasActiveAskUser: 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, hasActiveAskUser } = 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 hasActiveAskUser && key === "ArrowUp": - return { type: "ask-user-arrow", direction: -1 }; - case hasActiveAskUser && key === "ArrowDown": - return { type: "ask-user-arrow", direction: 1 }; + 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 hasActiveAskUser && key === "Escape": - return { type: "ask-user-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 AskUserKeyAction = +export type QuestionKeyAction = | { 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 QuestionKeyEvent = { key: string; ctrlKey: boolean; metaKey: boolean; @@ -93,10 +93,10 @@ export type AskUserKeyEvent = { defaultPrevented: boolean; }; -export const interpretAskUserKey = ( - event: AskUserKeyEvent, +export const interpretQuestionKey = ( + event: QuestionKeyEvent, context: { hasHighlight: boolean }, -): AskUserKeyAction | null => { +): QuestionKeyAction | 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/question-machine.ts similarity index 54% rename from packages/ui/src/composer/ask-user-machine.ts rename to packages/ui/src/composer/question-machine.ts index fdd8373b..48371e34 100644 --- a/packages/ui/src/composer/ask-user-machine.ts +++ b/packages/ui/src/composer/question-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 +// 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 { AskUserQuestion, ComposerAnswerEntry } 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 AnswerEntry = { +export type AnswerDraft = { selected: Set; freeText: string; }; -export type AskUserState = { +export type QuestionsState = { step: number; - answers: Map; + drafts: Map; }; -export const INITIAL_ASK_USER_STATE: AskUserState = { +export const INITIAL_QUESTIONS_STATE: QuestionsState = { 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 QuestionsEffect = | { 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-answers"; answers: ComposerAnswer[] }; -export type AskUserAction = +export type QuestionsAction = | { 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 QuestionsTransition = { + next: QuestionsState; + effects: QuestionsEffect[]; }; // --------------------------------------------------------------------------- -// 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 = (): AnswerDraft => ({ 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,49 +111,48 @@ 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 }`. +// 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 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 }; + state: QuestionsState, + questions: ComposerQuestion[], +): ComposerAnswer[] => + questions.map((question, index) => { + const draft = state.drafts.get(index); + 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 = + 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: question.id, selected } : { id: question.id, selected, text }; }); -export const isLastStep = (state: AskUserState, questions: AskUserQuestion[]) => +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. transitionAskUser is the single entry point. +// plus the effects to run. transitionQuestions is the single entry point. // --------------------------------------------------------------------------- -// Commit `nextAnswers`, advance one step, and reset the input — then either +// Commit `nextDrafts`, advance one step, and reset the input — then either // 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: AskUserState, - questions: AskUserQuestion[], - nextAnswers: Map, -): AskUserTransition => { - const next: AskUserState = { step: state.step + 1, answers: nextAnswers }; + state: QuestionsState, + questions: ComposerQuestion[], + nextDrafts: Map, +): QuestionsTransition => { + const next: QuestionsState = { step: state.step + 1, drafts: nextDrafts }; if (state.step < questions.length - 1) { return { next, @@ -173,39 +172,39 @@ 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: QuestionsState, targetStep: number, currentText: string, -): AskUserTransition => { +): QuestionsTransition => { 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: QuestionsState = { 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 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 transitionAskUser = ( - state: AskUserState, - questions: AskUserQuestion[], - action: AskUserAction, -): AskUserTransition => { +export const transitionQuestions = ( + state: QuestionsState, + questions: ComposerQuestion[], + action: QuestionsAction, +): QuestionsTransition => { const multiSelect = !!questions[state.step]?.multiSelect; switch (action.type) { @@ -213,34 +212,34 @@ export const transitionAskUser = ( 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: [] }; + const toggled = toggleAnswer(state.drafts, state.step, action.label, multiSelect); + if (multiSelect) return { next: { ...state, drafts: toggled }, effects: [] }; return advanceStep(state, questions, 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), + writeFreeText(state.drafts, state.step, action.freeText.trim(), multiSelect), ); case "dismiss-step": - return advanceStep(state, questions, skipStep(state.answers, 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); diff --git a/packages/ui/src/ask-user/ask-user.tsx b/packages/ui/src/composer/question.tsx similarity index 70% rename from packages/ui/src/ask-user/ask-user.tsx rename to packages/ui/src/composer/question.tsx index 0645a92d..91c48404 100644 --- a/packages/ui/src/ask-user/ask-user.tsx +++ b/packages/ui/src/composer/question.tsx @@ -1,10 +1,10 @@ "use client"; -// Headless ask-user questionnaire parts. 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 useAskUserOptions() / -// useAskUserOption(). 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,114 +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 question text 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 AskUserIdsContextValue = { labelId: string; stepLabelId: string }; +type QuestionIdsContextValue = { labelId: string; stepLabelId: string }; -const AskUserIdsContext = createContext({ labelId: "", stepLabelId: "" }); +const QuestionIdsContext = 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 ComposerQuestionProps = PrimitiveProps<"div">; -export const AskUserRoot = ({ className, render, style, ...elementProps }: AskUserRootProps) => { +export const ComposerQuestion = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionProps) => { const labelId = useId(); const stepLabelId = useId(); const element = useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user": "" }, elementProps] }, + { props: [{ "data-composer-question": "" }, 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">; +/** Question label text. Carries the id `Options` uses as its accessible name. */ +export type ComposerQuestionLabelProps = PrimitiveProps<"p">; -export const AskUserLabel = ({ className, render, style, ...elementProps }: AskUserLabelProps) => { - const { labelId } = use(AskUserIdsContext); +export const ComposerQuestionLabel = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionLabelProps) => { + const { labelId } = use(QuestionIdsContext); return useRenderElement( "p", { className, render, style }, - { props: [{ id: labelId || undefined, "data-ask-user-label": "" }, elementProps] }, + { props: [{ id: labelId || undefined, "data-composer-question-label": "" }, elementProps] }, ); }; /** Row container for `Label` and optional `Navigation`. */ -export type AskUserHeaderProps = PrimitiveProps<"div">; +export type ComposerQuestionHeaderProps = PrimitiveProps<"div">; -export const AskUserHeader = ({ className, render, style, ...elementProps }: AskUserHeaderProps) => +export const ComposerQuestionHeader = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionHeaderProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user-header": "" }, elementProps] }, + { props: [{ "data-composer-question-header": "" }, elementProps] }, ); /** Row container for `Previous`, `StepLabel`, and `Next`. */ -export type AskUserNavigationProps = PrimitiveProps<"div">; +export type ComposerQuestionNavigationProps = PrimitiveProps<"div">; -export const AskUserNavigation = ({ +export const ComposerQuestionNavigation = ({ className, render, style, ...elementProps -}: AskUserNavigationProps) => +}: ComposerQuestionNavigationProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user-navigation": "" }, elementProps] }, + { props: [{ "data-composer-question-navigation": "" }, elementProps] }, ); /** Navigate to the previous step. Default accessible name; override via aria-label. */ -export type AskUserPreviousProps = PrimitiveProps<"button">; +export type ComposerQuestionPreviousProps = PrimitiveProps<"button">; -export const AskUserPrevious = ({ +export const ComposerQuestionPrevious = ({ className, render, style, ...elementProps -}: AskUserPreviousProps) => +}: ComposerQuestionPreviousProps) => useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Previous question", "data-ask-user-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 AskUserNextProps = PrimitiveProps<"button">; +export type ComposerQuestionNextProps = PrimitiveProps<"button">; -export const AskUserNext = ({ className, render, style, ...elementProps }: AskUserNextProps) => +export const ComposerQuestionNext = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionNextProps) => useRenderElement( "button", { className, render, style }, - { props: [{ "aria-label": "Next question", "data-ask-user-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 AskUserStepLabelState = { current: number; total: number }; +export type ComposerQuestionStepLabelState = { current: number; total: number }; -export type AskUserStepLabelProps = Omit, "children"> & { - children?: ReactNode | ((state: AskUserStepLabelState) => ReactNode); +export type ComposerQuestionStepLabelProps = Omit, "children"> & { + children?: ReactNode | ((state: ComposerQuestionStepLabelState) => ReactNode); }; -export const AskUserStepLabel = ({ +export const ComposerQuestionStepLabel = ({ 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); +}: 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( @@ -135,7 +160,11 @@ export const AskUserStepLabel = ({ { className, render, style }, { props: [ - { id: stepLabelId || undefined, "data-ask-user-step-label": "", children: content }, + { + id: stepLabelId || undefined, + "data-composer-question-step-label": "", + children: content, + }, elementProps, ], }, @@ -161,10 +190,10 @@ const OptionsContext = createContext({ onItemHover: () => {}, }); -export const useAskUserOptions = () => use(OptionsContext); +export const useQuestionOptions = () => use(OptionsContext); -/** Imperative handle exposed by `AskUser.Options` for keyboard navigation. */ -export type AskUserOptionsHandle = { +/** 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; @@ -177,16 +206,16 @@ export type AskUserOptionsHandle = { highlightedValue: string | null; }; -export type AskUserOptionsProps = 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 AskUserOptions = ({ +export const ComposerQuestionOptions = ({ multiSelect = false, groupName = "", ref, @@ -195,11 +224,11 @@ export const AskUserOptions = ({ style, children, ...elementProps -}: AskUserOptionsProps) => { +}: ComposerQuestionOptionsProps) => { const registeredItems = useRef([]); const itemElements = useRef HTMLElement | null>>(new Map()); const containerRef = useRef(null); - const { labelId, stepLabelId } = use(AskUserIdsContext); + const { labelId, stepLabelId } = use(QuestionIdsContext); const [highlightedValue, setHighlightedValue] = useState(null); // Track highlight in a ref so imperative methods see latest value without re-binding. @@ -247,7 +276,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-composer-question]") ?? element; scrollIntoViewWithin(element.getBoundingClientRect(), element, boundary); }, []); @@ -315,7 +344,7 @@ export const AskUserOptions = ({ role: multiSelect ? "group" : "radiogroup", "aria-labelledby": labelId || undefined, "aria-describedby": stepLabelId || undefined, - "data-ask-user-options": "", + "data-composer-question-options": "", children, }, elementProps, @@ -353,22 +382,22 @@ const OptionContext = createContext({ onSelect: () => {}, }); -export const useAskUserOption = () => use(OptionContext); +export const useQuestionOption = () => use(OptionContext); -export type AskUserOptionState = { +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 AskUserOptionProps = PrimitiveProps<"label", AskUserOptionState> & { +export type ComposerQuestionOptionProps = PrimitiveProps<"label", ComposerQuestionOptionState> & { value?: string; selected?: boolean; onSelect?: () => void; }; -export const AskUserOption = ({ +export const ComposerQuestionOption = ({ value = "", selected = false, onSelect, @@ -377,7 +406,7 @@ export const AskUserOption = ({ style, children, ...elementProps -}: AskUserOptionProps) => { +}: ComposerQuestionOptionProps) => { const id = useId(); const { multiSelect, highlightedValue, register, onItemHover } = use(OptionsContext); const optionRef = useRef(null); @@ -406,7 +435,7 @@ export const AskUserOption = ({ role: multiSelect ? "checkbox" : "radio", "aria-checked": selected, tabIndex: isHighlighted ? 0 : -1, - "data-ask-user-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). @@ -449,86 +478,91 @@ const useItemRegistration = ( }; /** Flex column wrapper for `OptionLabel` and `OptionDescription`. */ -export type AskUserOptionContentProps = PrimitiveProps<"span">; +export type ComposerQuestionOptionContentProps = PrimitiveProps<"span">; -export const AskUserOptionContent = ({ +export const ComposerQuestionOptionContent = ({ className, render, style, ...elementProps -}: AskUserOptionContentProps) => +}: ComposerQuestionOptionContentProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-user-option-content": "" }, elementProps] }, + { props: [{ "data-composer-question-option-content": "" }, elementProps] }, ); /** Option title text. */ -export type AskUserOptionLabelProps = PrimitiveProps<"span">; +export type ComposerQuestionOptionLabelProps = PrimitiveProps<"span">; -export const AskUserOptionLabel = ({ +export const ComposerQuestionOptionLabel = ({ className, render, style, ...elementProps -}: AskUserOptionLabelProps) => +}: ComposerQuestionOptionLabelProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-user-option-label": "" }, elementProps] }, + { props: [{ "data-composer-question-option-label": "" }, elementProps] }, ); /** Option subtitle/description text. */ -export type AskUserOptionDescriptionProps = PrimitiveProps<"span">; +export type ComposerQuestionOptionDescriptionProps = PrimitiveProps<"span">; -export const AskUserOptionDescription = ({ +export const ComposerQuestionOptionDescription = ({ className, render, style, ...elementProps -}: AskUserOptionDescriptionProps) => +}: ComposerQuestionOptionDescriptionProps) => useRenderElement( "span", { className, render, style }, - { props: [{ "data-ask-user-option-description": "" }, elementProps] }, + { props: [{ "data-composer-question-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 ComposerQuestionHintsProps = PrimitiveProps<"div">; -export const AskUserHints = ({ className, render, style, ...elementProps }: AskUserHintsProps) => +export const ComposerQuestionHints = ({ + className, + render, + style, + ...elementProps +}: ComposerQuestionHintsProps) => useRenderElement( "div", { className, render, style }, - { props: [{ "data-ask-user-hints": "" }, elementProps] }, + { props: [{ "data-composer-question-hints": "" }, elementProps] }, ); /** Dismiss/skip button. Stateless — wire `onClick` to your dismiss handler. */ -export type AskUserDismissProps = PrimitiveProps<"button">; +export type ComposerQuestionDismissProps = PrimitiveProps<"button">; -export const AskUserDismiss = ({ +export const ComposerQuestionDismiss = ({ className, render, style, ...elementProps -}: AskUserDismissProps) => +}: ComposerQuestionDismissProps) => useRenderElement( "button", { className, render, style }, - { props: [{ type: "button" as const, "data-ask-user-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 AskUserContinueProps = PrimitiveProps<"button">; +export type ComposerQuestionContinueProps = PrimitiveProps<"button">; -export const AskUserContinue = ({ +export const ComposerQuestionContinue = ({ className, render, style, ...elementProps -}: AskUserContinueProps) => +}: ComposerQuestionContinueProps) => useRenderElement( "button", { className, render, style }, - { props: [{ type: "submit" as const, "data-ask-user-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 9c7e8690..ba9a11c0 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, + ComposerQuestion, ComposerSnapshot, ComposerSubmitData, } from "./types"; @@ -38,7 +38,7 @@ export type ComposerRootProps = Omit, onSubmit?: (data: ComposerSubmitData) => void | Promise; isSubmitting?: boolean; commands?: ComposerCommandsMap; - questions?: AskUserQuestion[]; + questions?: ComposerQuestion[]; defaultValue?: ComposerSnapshot; value?: ComposerSnapshot; onValueChange?: (snapshot: ComposerSnapshot) => void; @@ -79,9 +79,10 @@ 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. + // 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) => @@ -105,13 +106,16 @@ 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 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 activeQuestions = questions?.length ? questions : null; useEffect(() => { - store.setQuestions(questions ?? null); - if (!questions?.length) return; - return store.activateAskUser(); - }, [store, questions]); + store.setQuestions(activeQuestions); + if (!activeQuestions) return; + return store.activateQuestions(); + }, [store, activeQuestions]); const { getRegisteredPrefixes } = useCommandRegistry(commands); @@ -131,10 +135,10 @@ export const ComposerRoot = ({ const handleFormSubmit = async (event: React.SubmitEvent) => { event.preventDefault(); - const { askUser, attachments } = store.getSnapshot(); + const { questions, attachments } = store.getSnapshot(); - if (askUser.questions?.length) { - askUser.continueStep(store.controller.getText()); + if (questions.items?.length) { + questions.continueStep(store.controller.getText()); return; } @@ -166,7 +170,7 @@ export const ComposerRoot = ({ 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; + 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 431e813c..5eef3062 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 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 { interpretQuestionKey } from "./keyboard"; +import type { ComposerQuestionOptionsHandle } from "./question"; +import { + type AnswerDraft, + INITIAL_QUESTIONS_STATE, + isLastStep, + type QuestionsAction, + type QuestionsEffect, + transitionQuestions, +} from "./question-machine"; import type { - AskUserQuestion, - ComposerAnswerEntry, + ComposerAnswer, ComposerEditorHandle, + ComposerQuestion, RegisteredEditor, } from "./types"; @@ -90,10 +90,10 @@ export type ComposerAttachmentsState = { globalDropRef: RefObject; }; -export type ComposerAskUserState = ComposerPanelSlice & { - questions: AskUserQuestion[] | null; +export type ComposerQuestionsState = ComposerPanelSlice & { + items: ComposerQuestion[] | 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, 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 // 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; + questions: ComposerQuestionsState; }; // --------------------------------------------------------------------------- @@ -159,10 +159,10 @@ export type ComposerStore = { listboxId: string; moveHighlight: (direction: number) => void; setHighlight: (index: number) => void; - setQuestions: (questions: AskUserQuestion[] | null) => void; + setQuestions: (questions: ComposerQuestion[] | null) => void; setDragging: (active: boolean) => void; resetAttachments: () => void; - activateAskUser: () => () => 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; - submitAnswersRef: RefObject<((answers: ComposerAnswerEntry[]) => 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 = { }; // --------------------------------------------------------------------------- -// Ask-user keydown — the document-level handler for question mode, kept out of -// the store factory so activateAskUser 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 ask-user 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,22 +210,25 @@ 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-composer-question-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 AskUserKeydownDeps = { +type QuestionsKeydownDeps = { controller: ComposerEditorState; - optionsRef: RefObject; + optionsRef: RefObject; editorRef: RefObject; - dispatch: (action: AskUserAction) => void; + dispatch: (action: QuestionsAction) => 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 interpretQuestionKey one-to-one. +const createQuestionsKeydownHandler = + ({ controller, optionsRef, editorRef, dispatch }: QuestionsKeydownDeps) => (event: KeyboardEvent) => { const scopedOptionsElement = optionsRef.current?.getElement() ?? null; if ( @@ -239,7 +242,7 @@ const createAskUserKeydownHandler = } const optionsHandle = optionsRef.current; - const action = interpretAskUserKey( + const action = interpretQuestionKey( { key: event.key, ctrlKey: event.ctrlKey, @@ -303,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 @@ -318,7 +321,7 @@ export const createComposerStore = (): ComposerStore => { destroy: revokeAttachmentUrl, }, }; - const submitAnswersRef: RefObject<((answers: ComposerAnswerEntry[]) => void) | null> = { + const submitAnswersRef: RefObject<((answers: ComposerAnswer[]) => void) | null> = { current: null, }; const commandSelectRef: RefObject<(() => void) | null> = { current: null }; @@ -326,7 +329,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 questionsMachine = INITIAL_QUESTIONS_STATE; let snapshot: ComposerState; // --- Setters @@ -414,12 +417,12 @@ export const createComposerStore = (): ComposerStore => { notify(); }; - // --- Ask-user - // The execute half of the ask-user 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 executeAskUserEffects = (effects: AskUserEffect[]) => { + const executeQuestionsEffects = (effects: QuestionsEffect[]) => { for (const effect of effects) { switch (effect.type) { case "clear-input": @@ -448,57 +451,57 @@ export const createComposerStore = (): ComposerStore => { } }; - const dispatchAskUser = (action: AskUserAction) => { - const questions = snapshot.askUser.questions; + const dispatchQuestions = (action: QuestionsAction) => { + const questions = snapshot.questions.items; if (!questions || questions.length === 0) return; - const { next, effects } = transitionAskUser(askUserMachine, questions, action); - if (next !== askUserMachine) { - askUserMachine = next; + const { next, effects } = transitionQuestions(questionsMachine, questions, action); + if (next !== questionsMachine) { + questionsMachine = next; snapshot = { ...snapshot, - askUser: { - ...snapshot.askUser, + questions: { + ...snapshot.questions, step: next.step, - answers: next.answers, + drafts: next.drafts, isLastStep: isLastStep(next, questions), }, }; notify(); } - executeAskUserEffects(effects); + executeQuestionsEffects(effects); }; - const setQuestions = (questions: AskUserQuestion[] | null) => { - if (snapshot.askUser.questions === questions) return; - askUserMachine = INITIAL_ASK_USER_STATE; + const setQuestions = (questions: ComposerQuestion[] | null) => { + if (snapshot.questions.items === questions) return; + questionsMachine = INITIAL_QUESTIONS_STATE; snapshot = { ...snapshot, - askUser: { - ...snapshot.askUser, + questions: { + ...snapshot.questions, active: questions != 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, + 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(); }; - // Ask-user mode: while questions 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 activateAskUser = () => { - const handleKeyDown = createAskUserKeydownHandler({ + const activateQuestions = () => { + const handleKeyDown = createQuestionsKeydownHandler({ controller, optionsRef, editorRef, - dispatch: dispatchAskUser, + dispatch: dispatchQuestions, }); document.addEventListener("keydown", handleKeyDown); @@ -536,21 +539,21 @@ export const createComposerStore = (): ComposerStore => { fileInputRef, globalDropRef, }, - askUser: { + questions: { active: false, present: false, - questions: null, - step: askUserMachine.step, - answers: askUserMachine.answers, + items: null, + step: questionsMachine.step, + drafts: questionsMachine.drafts, isLastStep: false, isSingle: false, - toggleOption: (label) => dispatchAskUser({ type: "toggle-option", label }), + toggleOption: (label) => dispatchQuestions({ 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() }), + 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, }, }; @@ -563,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, askUser } = snapshot; - if (commands.present === commands.active && askUser.present === askUser.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 }, - askUser: - askUser.present === askUser.active ? askUser : { ...askUser, present: askUser.active }, + questions: + questions.present === questions.active + ? questions + : { ...questions, present: questions.active }, }; notify(); }; @@ -580,7 +585,7 @@ export const createComposerStore = (): ComposerStore => { // revoke attachment object URLs, then restore the pristine snapshot. const reset = () => { dispatchAttachments({ type: "reset" }); - askUserMachine = INITIAL_ASK_USER_STATE; + questionsMachine = INITIAL_QUESTIONS_STATE; commandSelectRef.current = null; snapshot = initialSnapshot; notify(); @@ -604,7 +609,7 @@ export const createComposerStore = (): ComposerStore => { setQuestions, setDragging, resetAttachments: () => dispatchAttachments({ type: "reset" }), - activateAskUser, + activateQuestions, finalizePanelClose, reset, editorRef, @@ -656,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 askUser = useComposer((composer) => composer.askUser); +// 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 45e57772..6d53b9d8 100644 --- a/packages/ui/src/composer/textarea.tsx +++ b/packages/ui/src/composer/textarea.tsx @@ -136,7 +136,7 @@ export const ComposerTextarea = ({
EngineDependencies) => { // Single-select questions 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 { 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, - hasActiveAskUser: (store.getSnapshot().askUser.questions?.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 "ask-user-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().askUser.optionsRef.current?.navigate(action.direction); + store.getSnapshot().questions.optionsRef.current?.navigate(action.direction); return; } - case "ask-user-dismiss": { + case "question-dismiss": { event.preventDefault(); - store.getSnapshot().askUser.dismissStep(); + store.getSnapshot().questions.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().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 e8c07a99..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 ask-user 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/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]; +const AskHarness = () => { + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; if (!question) return null; - const entry = askUser.answers.get(askUser.step); - const selected = entry?.selected ?? new Set(); + const draft = questions.drafts.get(questions.step); + const selected = draft?.selected ?? new Set(); return ( - - - {question.question} - - + + + {question.label} + + {question.options.map((option) => ( - askUser.toggleOption(option.label)} + onSelect={() => questions.toggleOption(option.label)} > - {option.label} - + {option.label} + ))} - - + + ); }; -// activateAskUser 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 () => { @@ -65,7 +66,7 @@ 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( @@ -75,7 +76,7 @@ describe("ask-user a11y", () => { submitted.push(data); }} > - + , ); await flushFrames(); @@ -99,12 +100,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" }], + answers: [{ id: "database", selected: ["MongoDB"] }], }, ]); @@ -115,7 +116,7 @@ describe("ask-user a11y", () => { test("multi-select checkboxes toggle aria-checked in place with Enter and Space", async () => { const { container, unmount } = render( {}}> - + , ); await flushFrames(); @@ -145,7 +146,7 @@ describe("ask-user a11y", () => { test("arrows still navigate after a chrome click drops focus to body", async () => { const { unmount } = render( {}}> - + , ); await flushFrames(); @@ -169,7 +170,7 @@ 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..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 { AskUser } from "../src/ask-user"; import { Composer } from "../src/composer"; import { useComposer } from "../src/composer/store"; -import type { AskUserQuestion, 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,30 +27,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: ComposerQuestion[] = [ + { + id: "database", + label: "Which database?", + options: [{ label: "PostgreSQL" }, { label: "SQLite" }], + }, ]; -const AskUserOptions = () => { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; +const RequestOptions = () => { + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; if (!question) return null; return ( - + {question.options.map((option) => ( - askUser.toggleOption(option.label)} + onSelect={() => questions.toggleOption(option.label)} > {option.label} - + ))} - + ); }; -// activateAskUser moves focus into the options one frame past the commit. +// activateQuestions moves focus into the options one frame past the commit. const flushFrames = () => act( () => @@ -120,13 +123,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 question is open; Escape dismisses its step", async () => { const onStop = mock(); const submitted: ComposerSubmitData[] = []; render( - void submitted.push(data)}> + void submitted.push(data)}> - + , ); diff --git a/packages/ui/tests/composer-keyboard.test.ts b/packages/ui/tests/composer-keyboard.test.ts index d8da47a2..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 { interpretAskUserKey, interpretEditorKey } from "../src/composer/keyboard"; +import { interpretEditorKey, interpretQuestionKey } from "../src/composer/keyboard"; const baseContext = { isCommandListOpen: false, - hasActiveAskUser: false, + hasActiveQuestions: 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("question arrows take over when active", () => { + const ctx = { ...baseContext, hasActiveQuestions: true }; expect(interpretEditorKey({ key: "ArrowUp", shiftKey: false }, ctx)).toEqual({ - type: "ask-user-arrow", + type: "question-arrow", direction: -1, }); }); @@ -55,8 +55,8 @@ describe("interpretEditorKey", () => { }); }); -describe("interpretAskUserKey", () => { - 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("interpretAskUserKey", () => { }); test("escape dismisses regardless of highlight", () => { - expect(interpretAskUserKey(event("Escape"), { hasHighlight: false })).toEqual({ + expect(interpretQuestionKey(event("Escape"), { hasHighlight: false })).toEqual({ type: "dismiss-step", }); }); test("without highlight, other keys pass through", () => { - expect(interpretAskUserKey(event("ArrowDown"), { hasHighlight: false })).toBeNull(); + expect(interpretQuestionKey(event("ArrowDown"), { hasHighlight: false })).toBeNull(); }); test("with highlight, arrows navigate and enter selects", () => { - expect(interpretAskUserKey(event("ArrowDown"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("ArrowDown"), { hasHighlight: true })).toEqual({ type: "navigate-options", direction: 1, }); - expect(interpretAskUserKey(event("Enter"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("Enter"), { hasHighlight: true })).toEqual({ type: "select-option", }); - expect(interpretAskUserKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("ArrowLeft"), { hasHighlight: true })).toEqual({ type: "go-back", }); }); test("printable characters route back to the input", () => { - expect(interpretAskUserKey(event("x"), { hasHighlight: true })).toEqual({ + expect(interpretQuestionKey(event("x"), { hasHighlight: true })).toEqual({ type: "insert-character", character: "x", }); - expect(interpretAskUserKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull(); + expect(interpretQuestionKey(event("x", { metaKey: true }), { hasHighlight: true })).toBeNull(); }); test("defaultPrevented events are ignored", () => { expect( - interpretAskUserKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }), + interpretQuestionKey(event("Escape", { defaultPrevented: true }), { hasHighlight: true }), ).toBeNull(); }); }); @@ -142,10 +142,10 @@ describe("interpretEditorKey: submitOn", () => { }); }); -describe("interpretAskUserKey: Space selects", () => { +describe("interpretQuestionKey: Space selects", () => { test("Space toggles the highlighted option (APG radio/checkbox)", () => { expect( - interpretAskUserKey( + interpretQuestionKey( { 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: question dismiss", () => { test("editor-focused Escape dismisses the active question", () => { expect( interpretEditorKey( { key: "Escape", shiftKey: false }, - { ...baseContext, hasActiveAskUser: true }, + { ...baseContext, hasActiveQuestions: true }, ), - ).toEqual({ type: "ask-user-dismiss" }); + ).toEqual({ type: "question-dismiss" }); }); test("command list still owns Escape when both are active", () => { expect( interpretEditorKey( { key: "Escape", shiftKey: false }, - { ...baseContext, hasActiveAskUser: true, isCommandListOpen: true }, + { ...baseContext, hasActiveQuestions: true, isCommandListOpen: true }, ), ).toEqual({ type: "command-close" }); }); diff --git a/packages/ui/tests/composer-question-machine.test.ts b/packages/ui/tests/composer-question-machine.test.ts new file mode 100644 index 00000000..b61e81e1 --- /dev/null +++ b/packages/ui/tests/composer-question-machine.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { + compileAnswers, + INITIAL_QUESTIONS_STATE, + isLastStep, + type QuestionsState, + transitionQuestions, +} from "../src/composer/question-machine"; +import type { ComposerQuestion } from "../src/composer/types"; + +const questions: ComposerQuestion[] = [ + { 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("transitionQuestions", () => { + test("select-option on single-select advances a step and clears input", () => { + const { next, effects } = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, { + 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: QuestionsState = { step: 1, drafts: new Map() }; + const { next, effects } = transitionQuestions(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.drafts.get(1)?.selected.has("B")).toBe(true); + }); + + 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 = 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-answers"); + expect(submit).toBeDefined(); + if (submit?.type !== "submit-answers") throw new Error("unreachable"); + expect(submit.answers).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 = transitionQuestions({ step: 1, drafts: new Map() }, questions, { + type: "toggle-option", + label: "B", + }); + const second = transitionQuestions(first.next, questions, { + type: "toggle-option", + label: "B", + }); + expect(second.next.drafts.get(1)?.selected.size).toBe(0); + }); + + test("dismiss-step skips the draft entirely", () => { + const { next } = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, { + 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: QuestionsState = { + step: 1, + drafts: new Map([[0, { selected: new Set(), freeText: "saved" }]]), + }; + const { next, effects } = transitionQuestions(state, questions, { + 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 = transitionQuestions(INITIAL_QUESTIONS_STATE, questions, { + type: "step-back", + currentText: "", + }); + expect(atStart.next).toBe(INITIAL_QUESTIONS_STATE); + expect(atStart.effects).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: QuestionsState = { + step: 0, + drafts: new Map([[0, { selected: new Set(), freeText: " custom " }]]), + }; + expect(compileAnswers(state, questions)[0]).toEqual({ id: "q1", selected: [], text: "custom" }); + }); + + test("text is omitted entirely when the user typed nothing", () => { + const state: QuestionsState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["A"]), freeText: "" }]]), + }; + 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: ComposerQuestion[] = [ + { + 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: QuestionsState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["Next.js", "Remix"]), freeText: "" }]]), + }; + 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: ComposerQuestion[] = [ + { + 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: QuestionsState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["b"]), freeText: "" }]]), + }; + expect(compileAnswers(state, colliding)[0]).toEqual({ id: "pick", selected: ["b"] }); + }); + + test("keys already stored as values resolve to the same values", () => { + const valued: ComposerQuestion[] = [ + { id: "db", label: "Database?", options: [{ value: "pg", label: "PostgreSQL" }] }, + ]; + const state: QuestionsState = { + step: 0, + drafts: new Map([[0, { selected: new Set(["pg"]), freeText: "" }]]), + }; + expect(compileAnswers(state, valued)[0]).toEqual({ id: "db", selected: ["pg"] }); + }); +}); + +describe("isLastStep", () => { + 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 f918dd5c..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 { AskUser } from "../src/ask-user"; 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 { 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 ask-user 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,35 +174,39 @@ describe("scrollIntoViewWithin", () => { }); // --------------------------------------------------------------------------- -// The ask-user flow's own focus moves +// The question flow's own focus moves // --------------------------------------------------------------------------- -const QUESTIONS: AskUserQuestion[] = [ - { question: "Which database?", options: [{ label: "PostgreSQL" }, { label: "SQLite" }] }, +const REQUESTS: ComposerQuestion[] = [ + { + id: "database", + label: "Which database?", + options: [{ label: "PostgreSQL" }, { label: "SQLite" }], + }, ]; -const AskUserOptions = () => { - const askUser = useComposer((composer) => composer.askUser); - const question = askUser.questions?.[askUser.step]; +const RequestOptions = () => { + const questions = useComposer((composer) => composer.questions); + const question = questions.items?.[questions.step]; if (!question) return null; return ( - - + + {question.options.map((option) => ( - askUser.toggleOption(option.label)} + onSelect={() => questions.toggleOption(option.label)} > {option.label} - + ))} - - + + ); }; -// activateAskUser moves focus into the options one frame past the commit. +// activateQuestions moves focus into the options one frame past the commit. const flushFrames = () => act( () => @@ -212,7 +215,7 @@ const flushFrames = () => }), ); -describe("ask-user 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 }[] = []; @@ -230,9 +233,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..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("ask-user presence is sticky the same way", () => { + test("question 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.setQuestions([{ id: "pick", label: "Pick one", options: [{ label: "A" }] }]); + expect(store.getSnapshot().questions.active).toBe(true); + expect(store.getSnapshot().questions.present).toBe(true); store.setQuestions(null); - expect(store.getSnapshot().askUser.active).toBe(false); - expect(store.getSnapshot().askUser.present).toBe(true); + expect(store.getSnapshot().questions.active).toBe(false); + expect(store.getSnapshot().questions.present).toBe(true); store.finalizePanelClose(); - expect(store.getSnapshot().askUser.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(), });