Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/generic-requests.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 22 additions & 22 deletions CHAT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -88,7 +88,7 @@ The nesting hierarchy at a glance:
<Composer.CommandLoading />
<Composer.CommandEmpty />
</Composer.CommandList>
<Composer.AskUser />
<Composer.Question />
</Composer.PanelItem>
</Composer.Panel>

Expand All @@ -100,9 +100,9 @@ The nesting hierarchy at a glance:
<Composer.Placeholder />
</Composer.Textarea>
<Composer.Actions>
<Composer.AskUserHints />
<Composer.AskUserDismiss />
<Composer.AskUserContinue />
<Composer.QuestionHints />
<Composer.QuestionDismiss />
<Composer.QuestionContinue />
<Composer.Submit />
</Composer.Actions>
</Composer.Container>
Expand All @@ -118,7 +118,7 @@ export type ComposerRootProps = Omit<ComponentProps<"form">, "onSubmit" | "ref">
onSubmit?: (data: ComposerSubmitData) => void | Promise<void>;
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;
Expand All @@ -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
};
```

Expand All @@ -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 |
Expand Down Expand Up @@ -186,22 +186,22 @@ The same controller surface is also exported as the module singleton **`composer
<Composer.Panel value={panelState.type}>
<Composer.PanelItem value="command-list">…</Composer.PanelItem>
<Composer.PanelItem value="active">…</Composer.PanelItem>
<Composer.PanelItem value="ask-user"><Composer.AskUser /></Composer.PanelItem>
<Composer.PanelItem value="questions"><Composer.Question /></Composer.PanelItem>
</Composer.Panel>
```

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 `<AskUserHints /> <AskUserDismiss /> <AskUserContinue />`.
When in question mode, swap the `Actions` row from the standard layout to `<Composer.QuestionHints /> <Composer.QuestionDismiss /> <Composer.QuestionContinue />`.

### Commands — prefix-triggered popovers

Expand Down Expand Up @@ -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 } })`.

Expand Down Expand Up @@ -597,7 +597,7 @@ const ChatSurface = ({ chatId }: { chatId: string }) => {
<Composer.Panel value={panelState.type}>
<Composer.PanelItem value="command-list">{/* CommandLists */}</Composer.PanelItem>
<Composer.PanelItem value="active">{/* StepQueue */}</Composer.PanelItem>
<Composer.PanelItem value="ask-user"><Composer.AskUser /></Composer.PanelItem>
<Composer.PanelItem value="questions"><Composer.Question /></Composer.PanelItem>
</Composer.Panel>

<Composer.Container>
Expand All @@ -608,9 +608,9 @@ const ChatSurface = ({ chatId }: { chatId: string }) => {
<Composer.Actions>
{panelState.type === "ask-user" ? (
<>
<Composer.AskUserHints />
<Composer.AskUserDismiss />
<Composer.AskUserContinue />
<Composer.QuestionHints />
<Composer.QuestionDismiss />
<Composer.QuestionContinue />
</>
) : (
<Composer.Submit />
Expand Down
46 changes: 23 additions & 23 deletions COMPOSER.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down Expand Up @@ -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)
Expand All @@ -67,7 +67,7 @@ If the editor is empty but attachments exist, `text` is set to `"Sent with attac
| `onSubmit` | `(data: ComposerSubmitData) => void \| Promise<void>` | 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. |
Expand Down Expand Up @@ -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`

Expand All @@ -154,8 +154,8 @@ The panel is the area above the editor that shows command lists, active tool pro
<Composer.PanelItem value="active">
<ActiveSteps />
</Composer.PanelItem>
<Composer.PanelItem value="ask-user">
<Composer.AskUser />
<Composer.PanelItem value="questions">
<Composer.Question />
</Composer.PanelItem>
</Composer.Panel>
```
Expand Down Expand Up @@ -261,39 +261,39 @@ When the filter matches nothing, the empty row acts as the single highlighted op

The default `<Composer.Commands />` 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
<Composer onSubmit={handleSubmit} questions={askUserQuestions}>
<Composer.Panel value="ask-user">
<Composer.PanelItem value="ask-user">
<Composer.AskUser />
<Composer onSubmit={handleSubmit} questions={composerQuestions}>
<Composer.Panel value="questions">
<Composer.PanelItem value="questions">
<Composer.Question />
</Composer.PanelItem>
</Composer.Panel>
<Composer.Container>
<Composer.Textarea>
<Composer.Placeholder placeholder="Type an answer..." />
</Composer.Textarea>
<Composer.Actions className="flex justify-end gap-2">
<Composer.AskUserHints />
<Composer.AskUserDismiss />
<Composer.AskUserContinue />
<Composer.QuestionHints />
<Composer.QuestionDismiss />
<Composer.QuestionContinue />
</Composer.Actions>
</Composer.Container>
</Composer>
```

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

Expand All @@ -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)
```
Expand All @@ -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`.

Expand Down
2 changes: 0 additions & 2 deletions MESSAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ Renders an assistant turn's reasoning + tool activity as a collapsible timeline
<Steps.Body>{markdown}</Steps.Body>
</Steps.Step>
<Steps.ToolCall part={toolPart} />
<Steps.AskUser part={askUserPart} />
</Steps.Content>
</Steps>;
```
Expand All @@ -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
Expand Down
Loading
Loading