Skip to content
Open
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
30 changes: 30 additions & 0 deletions .changeset/generic-requests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@intentface/chat": minor
---

The ask-user flow is now a generic request flow: consumer-minted ids and option values replace display-text identity, so tool approvals (and any future choice put to the user) need no new types. One term throughout — requests in, resolved requests back.

Migration:

| Before | After |
| --- | --- |
| prop `questions` | `requests` (entries now require an `id` you mint) |
| `AskUserQuestion` | `ComposerRequest`; field `question` → `label` |
| `AskUserOption` | `ComposerRequestOption` (gains optional `value`, echoed back; falls back to `label`) |
| `ComposerAnswerEntry` (4-variant union) | `ComposerRequestEntry` — flat `{ id, selected: string[], text? }`; `text` present only when typed; skipped entries have `selected: []` |
| `ComposerAnswersSubmit`, `kind: "answers"`, `data.answers` | `ComposerRequestsSubmit`, `kind: "requests"`, `data.requests` |
| `ComposerAskUserState`, slice `composer.askUser` | `ComposerRequestsState`, slice `composer.requests` (fields `questions` → `items`, `answers` → `drafts: Map<number, RequestDraft>`) |
| store `setQuestions` / `activateAskUser` / `submitAnswersRef` | `setRequests` / `activateRequests` / `submitRequestsRef` |
| `interpretAskUserKey` | `interpretRequestKey` |
| `EditorKeyContext.hasActiveAskUser` | `hasActiveRequests` |
| `EditorKeyAction` `"ask-user-arrow"` / `"ask-user-dismiss"` | `"request-arrow"` / `"request-dismiss"` |
| subpath `@intentface/chat/ask-user` | `@intentface/chat/ask` |
| namespace `AskUser.*`, hooks `useAskUserOption(s)`, `AskUser*Props` | `Ask.*`, `useAskOption(s)`, `Ask*Props` |
| attributes `data-ask-user-*` | `data-ask-*` |

Also in this change:

- `requests={[]}` no longer arms request mode — an empty array normalizes to `null`, so the slice can't read active with nothing to render.
- The request flow's document keydown listener no longer claims events from unrelated elements; it accepts its own editor, its own options, and the `<body>` focus fallback it exists for. Space on an unrelated button can no longer select an option.
- `Ask.Previous` / `Ask.Next` default accessible names are now "Previous request" / "Next request" (override via `aria-label`), since the flow is no longer questions-only.
- A disabled composer's hidden form mirror is unchanged here — tracked separately in CHAT-28.
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`, `Ask`.

### AI Integration

Expand Down
50 changes: 25 additions & 25 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,
Ask, AskHints, AskDismiss, AskContinue,
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.Ask />
</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.AskHints />
<Composer.AskDismiss />
<Composer.AskContinue />
<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
requests?: ComposerRequest[]; // present → drives the request flow
defaultValue?: ComposerSnapshot; // uncontrolled editor doc
value?: ComposerSnapshot; // controlled editor doc
onValueChange?: (snapshot: ComposerSnapshot) => void;
Expand All @@ -136,13 +136,13 @@ type ComposerMessageSubmit = {
files: FileUIPart[];
};

type ComposerAnswersSubmit = {
kind: "answers";
answers: ComposerAnswerEntry[]; // emitted by the ask-user flow
type ComposerRequestsSubmit = {
kind: "requests";
requests: ComposerRequestEntry[]; // emitted by the request flow
};
```

Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.sendMessage`, an `"answers"` submit goes to `addToolOutput` (resolving the open `askUser` tool call).
Parent components dispatch on `data.kind`: a `"message"` submit goes to `chat.sendMessage`, a `"requests"` submit goes to `addToolOutput` (resolving the open `askUser` tool call).

**`ComposerSnapshot`** is an opaque, branded wrapper around the editor's paragraph JSON (`{ __doc, __brand }`) used by the controlled `value` / `defaultValue` API — distinct from `Composer.Textarea`'s plain-string `value`. Treat it as a token: persist it and hand it back, but don't read into `__doc`.

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, requests, panel, commands, isSubmitting } = useComposer();
```

| Slice | Shape |
| ------------- | --------------------------------------------------------------------------- |
| `textarea` | The editor controller (`focus/blur/clear/insertText/insertChip/getText/setText/serialize/ensureFocus`) plus reactive `hasContent` |
| `attachments` | `{ items, add, remove, openFileDialog, error, isDragging, fileInputRef, … }` |
| `askUser` | `{ questions, step, answers, toggleOption, continueStep, dismissStep, isLastStep, isSingle, goBack, goNext, … }` |
| `requests` | `{ items, step, drafts, toggleOption, continueStep, dismissStep, isLastStep, isSingle, goBack, goNext, … }` |
| `panel` | `{ isOpen, value }` — which panel item is open |
| `commands` | `{ isOpen, trigger, query }` — prefix-popover state |
| `isSubmitting`| Boolean mirror of the root's `isSubmitting` prop |
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="ask-user"><Composer.Ask /></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`
### Requests — `Composer.Ask` / `AskHints` / `AskDismiss` / `AskContinue`

Active when the root receives a non-empty `questions` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `askUser` store slice.
Active when the root receives a non-empty `requests` prop (driven by the open `askUser` tool call). It's a multi-step state machine over the `requests` store slice.

- `Composer.AskUser` — renders the current question with its options and a free-text fallback; handles single- and multi-select, plus prev/next navigation across questions.
- `Composer.AskUserHints` — keyboard-hint pills (↑↓ navigate, ↵ select, ←→ between questions, esc skip).
- `Composer.AskUserDismiss` — skips the current question (`askUser.dismissStep`).
- `Composer.AskUserContinue` — submits the form; labeled `"Continue"`, or `"Submit"` on the last step. The form handler routes it through `askUser.continueStep`, which compiles per-question answers into the `ComposerAnswerEntry` union and fires `onSubmit({ kind: "answers", answers })`.
- `Composer.Ask` — renders the current question with its options and a free-text fallback; handles single- and multi-select, plus prev/next navigation across questions.
- `Composer.AskHints` — keyboard-hint pills (↑↓ navigate, ↵ select, ←→ between questions, esc skip).
- `Composer.AskDismiss` — skips the current question (`requests.dismissStep`).
- `Composer.AskContinue` — submits the form; labeled `"Continue"`, or `"Submit"` on the last step. The form handler routes it through `requests.continueStep`, which compiles per-request entries (flat `ComposerRequestEntry`) and fires `onSubmit({ kind: "requests", requests })`.

When in ask-user mode, swap the `Actions` row from the standard layout to `<AskUserHints /> <AskUserDismiss /> <AskUserContinue />`.
When in request mode, swap the `Actions` row from the standard layout to `<AskHints /> <AskDismiss /> <AskContinue />`.

### 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 `requests` is active and the user is mid-flow → `requests.continueStep()` advances or finalises (emitting `{ kind: "requests" }`).
3. Otherwise → `serializeEditorContent` produces `{ text }` (chips already inlined), attachments become `FileUIPart[]`, the editor and attachments reset, and the root calls `onSubmit({ kind: "message", text, files })`. No `chips` or `tools` field — chips live in `text`, tool toggles live in the consumer.
4. The parent maps that to `chat.sendMessage({ parts: [...files, { type: "text", text }] }, { body: { webSearch, thinking } })`.

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="ask-user"><Composer.Ask /></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.AskHints />
<Composer.AskDismiss />
<Composer.AskContinue />
</>
) : (
<Composer.Submit />
Expand Down
44 changes: 22 additions & 22 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 requests. 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 @@ -43,11 +43,11 @@ type ComposerSubmitData =
chips: ChipData[]; // inline mention chips
}
| {
kind: "answers";
answers: ComposerAnswerEntry[]; // produced by the questionnaire flow
kind: "requests";
requests: ComposerRequestEntry[]; // produced by the request flow
};

type ComposerAnswerEntry =
type ComposerRequestEntry =
| { question: string; option: string } // single-select, picked an option
| { question: string; text: string } // single-select, typed free text
| { question: string; options: string[]; text: string } // multi-select (either field may be empty)
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). |
| `requests` | `ComposerRequest[]` | When present, the composer enters request mode. See [Requests](#add-on-requests). |
| `defaultValue` | `ComposerSnapshot` | Initial uncontrolled editor content. |
| `value` | `ComposerSnapshot` | Controlled editor content. |
| `onValueChange` | `(snapshot) => void` | Fires on editor change. |
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 request 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 @@ -155,7 +155,7 @@ The panel is the area above the editor that shows command lists, active tool pro
<ActiveSteps />
</Composer.PanelItem>
<Composer.PanelItem value="ask-user">
<Composer.AskUser />
<Composer.Ask />
</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: requests

Pass `questions` to switch the composer into structured-question mode. The default renderer covers the flow:
Pass `requests` to switch the composer into request mode — clarifying questions, tool approvals, any choice put to the user. The default renderer covers the flow:

```tsx
<Composer onSubmit={handleSubmit} questions={askUserQuestions}>
<Composer onSubmit={handleSubmit} requests={composerRequests}>
<Composer.Panel value="ask-user">
<Composer.PanelItem value="ask-user">
<Composer.AskUser />
<Composer.Ask />
</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.AskHints />
<Composer.AskDismiss />
<Composer.AskContinue />
</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.Ask` — full default UI (question text, options, step counter, nav arrows).
- `Composer.AskHints` — keyboard-hint footer (↑↓ ↵ ← → Esc).
- `Composer.AskDismiss` — skip the current question (`Esc`).
- `Composer.AskContinue` — advance / submit (`Enter`). Auto-toggles label between "Continue" and "Submit".

Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between questions, **Esc** dismiss, printable keys type free-text.
Keyboard: **↑/↓** navigate options, **Enter** select/advance, **←/→** between requests, **Esc** dismiss, printable keys type free-text.

On completion, `onSubmit` fires with `{ kind: "answers", answers }` where `answers` is a `ComposerAnswerEntry[]` (one entry per question, in order). See [Submit data](#submit-data) for the entry shape.
On completion, `onSubmit` fires with `{ kind: "requests", requests }` where `requests` is a `ComposerRequestEntry[]` (one entry per request, 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 requests = useComposer((composer) => composer.requests); // request machine + actions
const attachments = useComposer((composer) => composer.attachments); // add, remove, openFileDialog, items, error
const commands = useComposer((composer) => composer.commands); // open command-list state (isOpen, trigger, query)
```
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`, `ComposerRequestsSubmit`, `ComposerRequestEntry`, `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