From aa6fc5adccdf28b6915324740e4988e72868a919 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 17:16:25 +0200 Subject: [PATCH 1/9] feat(chat): one shared chat context for ChatInput.Root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat.Root accepts chat={useChat()} and folds the session into the shared ChatContext, and the composer (ChatInput.Root and the batteries ChatInput) falls back to that context when its explicit props (input, onSubmit, model, models, onModelChange, attachments, onRemoveAttachment, stop, onAttach) are omitted — including the attach picker and drop zone. Explicit props always win, and a standalone composer with its own props is unchanged. Resolving the review round: - The batteries no longer defaults isLoading to false, so an omitted prop inherits the session's loading state and Stop replaces Send during a turn. - The default composer body renders the context-resolved attachments and remove handler, so a contextual attachment shows its pill and control. - Chat.Root distinguishes an omitted prop from an explicit null, so error={null} / streamingMessageId={null} override the session instead of restoring it. - Makes `input`/`onChange` optional on `ChatInputProps`, so a propless `` under a `` typechecks the way `ChatInput.Root` already did. - Regenerates docs/api-reference/veryfront/chat.md for the shifted source lines (the stale generated reference that failed ci (lint)). Ref: veryfront-issue-inbox#69 --- docs/api-reference/veryfront/chat.md | 152 +++---- .../chat-composer.shared-context.test.tsx | 372 ++++++++++++++++++ .../chat/chat/composition/chat-composer.tsx | 36 +- .../chat/composition/chat-composer.types.ts | 17 +- .../chat/chat/composition/chat-root.tsx | 75 +++- .../chat/composition/use-composer-value.ts | 35 +- .../dev/framework-candidates.generated.ts | 119 +++++- 7 files changed, 683 insertions(+), 123 deletions(-) create mode 100644 src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index acaf3ef1af..088bf17b1c 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -137,79 +137,79 @@ Result returned from use agent. ### Components -| Name | Description | Source | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `AgentAvatar` | Render agent avatar, falling back to model identity when agent identity is absent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/agent-avatar.tsx#L24) | -| `AgentCard` | AgentCard - render `` for the default card, or compose `AgentCard.Header` / `Reasoning` / `Tools` / `Body` for a custom layout. Mirrors the `ToolCall` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/agent-card.tsx#L270) | -| `AgentPicker` | AgentPicker - render `` for the default data-driven combobox, or compose `AgentPicker.Trigger`, `Content`, `Search`, `List`, `Item`, `Create`, and `Manage` for a custom menu. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/agent-picker.tsx#L420) | -| `AppShell` | Compound AppShell. Compose: | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/app-shell.tsx#L628) | -| `AttachmentPill` | AttachmentPill - render `` for the default chip, or compose `AttachmentPill.Root` + `.Thumbnail` / `.Icon` / `.Label` / `.Retry` / `.Remove` for a custom layout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachment-pill.tsx#L495) | -| `AttachmentsPanel` | AttachmentsPanel - render `` for the default panel, or compose `AttachmentsPanel.Root` + `List` / `Item` / `Empty` / `Action` for a custom layout. Mirrors the `ToolCall` / `Sources` compounds: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachments-panel.tsx#L650) | -| `BranchPicker` | Branch picker with addressable previous, count, and next leaves. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/branch-picker.tsx#L192) | -| `Chat` | Render chat components through the preset or its composable sub-parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/chat-preset.tsx#L37) | -| `ChatActions` | ChatActions - render `` for the default preset menu, or compose `ChatActions.Trigger` / `Content` / `Item` (each reads `useChatActions()`) for a custom menu. Mirrors the `ToolCall` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-actions.tsx#L197) | -| `ChatAgentPicker` | Render the connected agent switcher, or nothing when there's nothing to switch. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-agent-picker.tsx#L63) | -| `ChatContextProvider` | Render chat context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L106) | -| `ChatEmpty` | Render the chat empty state. It renders whatever it is given and never hides itself: in a custom layout, gate it on the thread being empty with ` ctx.isEmpty}>`. The `` preset does this for you. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty.tsx#L50) | -| `ChatEmptyState` | Compound empty state. Use the namespaced parts to compose the view: `Root`, `Avatar`, `Heading`, `Suggestions`, `Suggestion`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty-state.tsx#L169) | -| `ChatIf` | Render chat if. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-if.tsx#L19) | -| `ChatInput` | ChatInput - render `` for the default composer, or compose `ChatInput.Field` + `ChatInput.Send`/`Stop`/`Voice`/`Model`/`Attach`/`Export`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L327) | -| `ChatInputAttach` | Attachment `+` control. When attaching files is the only action, `+` opens the file dialog directly. When `onSelectAttachment` is also set it becomes a portalled `+` menu (Studio `PromptForm`'s `PlusMenu`) with "Add photos & files" and "Select document". | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-attach.tsx#L30) | -| `ChatInputContextProvider` | Provider for `` context (RFC 2980 name). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L87) | -| `ChatInputExport` | Download the supplied conversation as Markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L92) | -| `ChatInputField` | The multiline text editor. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L48) | -| `ChatInputModel` | Model selector - shows when models are configured. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L74) | -| `ChatInputRoot` | `ChatInput.Root` - the provider shell for a fully custom composer. Supplies `ChatInputContext` from props and renders your children, so you arrange `ChatInput.Field` + the toolbar sub-parts yourself (like `Message.Root`). The default `` is exactly this Root plus the standard body. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L149) | -| `ChatInputSend` | Send button, shown while the composer is idle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L40) | -| `ChatInputStop` | Stop button, shown while the composer is streaming. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L80) | -| `ChatInputSubmit` | Switch between send and stop states with one control. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-submit.tsx#L22) | -| `ChatInputToolbar` | `ChatInput.Toolbar` - a semantic layout slot for the composer's action row. Group/reorder the action sub-parts (`ChatInput.Attach`/`.Model`/`.Export`/ `.Voice`/`.Send`) inside it without re-implementing the composer. Pure layout: the children read their own `ChatInputContext`, so `` just mirrors the default action-row wrapper classes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L123) | -| `ChatInputVoice` | Voice button, shown when the idle composer is empty. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L118) | -| `ChatMessageList` | Render the default message list or compose its centered `Content` column. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-message-list.tsx#L264) | -| `ChatMessagesSkeleton` | Render the loading skeleton for a chat thread. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/chat-messages-skeleton.tsx#L41) | -| `ChatRoot` | Render chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L87) | -| `ChatSidebar` | Render a chat sidebar - usable as `` or `…`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L621) | -| `ChatThemeScope` | Wrap chat primitives in the `[data-vf-ui]` token scope so they're themed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-theme-scope.tsx#L30) | -| `CodeBlock` | Render escaped source or delegate to explicit syntax/diagram capabilities. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L270) | -| `CodeSurface` | Render through an explicit extension capability or escaped plain source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L239) | -| `ComposerContextProvider` | Render composer context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L70) | -| `CONVERSATION_STORAGE_LIMITS` | Explicit resource limits applied on both the read and write paths. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/persistence/conversation-codec.ts#L48) | -| `ConversationEmptyState` | State for conversation empty. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L71) | -| `ConversationsContextProvider` | Low-level context provider (value supplied by the caller). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/conversations-context.tsx#L45) | -| `ConversationScrollButton` | Render conversation scroll button. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L116) | -| `ConversationsProvider` | ConversationsProvider - calls `useConversations` once with your `store` / `id` / `onSelect` and shares it via `ConversationsContext`. Declare persistence + router wiring here, once, at the app layout; children read it with `useConversationsContext`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/conversations-context.tsx#L58) | -| `CopyButton` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L191) | -| `DEFAULT_CHAT_STREAM_IDLE_TIMEOUT_MS` | Default value for chat stream idle timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L13) | -| `DEFAULT_CHAT_STREAM_TOOL_RUNNING_TIMEOUT_MS` | Default value for chat stream tool running timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L15) | -| `DropZoneOverlay` | Drag overlay shown over the composer while files are dragged onto it - the glyph-in-a-circle + "Drop files" from Studio's `PromptForm`. Rendered inside a `relative` card; fills it and blurs the content behind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/drop-zone.tsx#L18) | -| `ErrorBanner` | Render error banner. Mirrors Studio's inline chat retry banner: an amber `warning` alert with a leading triangle icon and a small filled "Try again" button (not a low-emphasis error pill). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/error-banner.tsx#L38) | -| `FadeIn` | Render fade in. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L37) | -| `InferenceBadge` | Render inference badge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/inference-badge.tsx#L15) | -| `InlineCitation` | Render the default citation or compose its `Trigger` and `Card` parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/inline-citation.tsx#L308) | -| `Loader` | Render loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L14) | -| `Markdown` | Present Markdown source using an injected rich renderer or the explicit dependency-free plain-source contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/markdown.tsx#L175) | -| `Message` | Message - render `` for the default turn, or compose `Message.Root` + `Message.Header`/`Content`/`Actions`/… for a custom layout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/message.tsx#L775) | -| `MessageActionBar` | Context-free message actions with addressable `Copy`, `Copied`, `Regenerate`, and `Edit` icon leaves. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/message-actions.tsx#L211) | -| `MessageContextProvider` | Render message context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/message-context.tsx#L123) | -| `MessageEditForm` | Render message edit form. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/message-edit-form.tsx#L18) | -| `MessageFeedback` | Message feedback with addressable positive and negative action leaves. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/message-feedback.tsx#L168) | -| `ModelAvatar` | Render model avatar. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/model-avatar.tsx#L70) | -| `ModelSelector` | ModelSelector - render `` for the default data-driven combobox, or compose `ModelSelector.Trigger`, `Content`, `Search`, `List`, and `Item` for a custom menu. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/model-selector.tsx#L446) | -| `QuickActions` | Render quick actions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/quick-actions.tsx#L20) | -| `Reasoning` | Reasoning - render `` for the default disclosure, or compose `Reasoning.Trigger` + `Reasoning.Content` for a custom layout. Mirrors the `Message` / `ToolCall` compounds: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/reasoning.tsx#L233) | -| `RichCodeBlock` | Render rich code block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/code-block.tsx#L25) | -| `Shimmer` | Render shimmer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L5) | -| `SkillBadge` | Render skill badge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/skill-badge.tsx#L23) | -| `SourcePill` | Render a single source pill with hover preview and score-color behaviour. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sources.tsx#L165) | -| `Sources` | Sources - render `` for the default row, or compose `Sources.Root` + `Sources.List` + `Sources.Pill` for a custom layout. Mirrors the `ToolCall` / `Reasoning` compounds: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sources.tsx#L225) | -| `StepIndicator` | StepIndicator - render `` for the default divider, or compose `StepIndicator.Root` + `.Rule` / `.Label` for a custom layout. Mirrors the `ToolCall` / `Sources` compounds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/step-indicator.tsx#L141) | -| `Suggestion` | Render suggestion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L14) | -| `Suggestions` | Render suggestions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L49) | -| `Tabs` | Tablist container - delegates selection to the adapter, owns the size + look. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/tabs.tsx#L66) | -| `TabsItem` | Individual tab - renders as a button, or an anchor when `href` is set. The adapter's `tabs.Tab` supplies the `role="tab"` / `aria-selected` / `data-state` mechanics and composes selection onto the caller's `onClick` (caller's runs first, then the tab activates); this skin renders the visual pill and forwards native props/ref. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/tabs.tsx#L108) | -| `TabSwitcher` | Render tab switcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tab-switcher.tsx#L28) | -| `ToolCall` | ToolCall - render `` for the default card. `invoke_agent` calls use a child-agent card by default. Pass children to replace the default, or compose `ToolCall.Trigger` / `Body` / `Input` / `Output` / `Error` for a custom layout. Mirrors the `Message` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tool-ui.tsx#L913) | -| `ToolStatusBadge` | Render tool status badge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tool-ui.tsx#L100) | +| Name | Description | Source | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `AgentAvatar` | Render agent avatar, falling back to model identity when agent identity is absent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/agent-avatar.tsx#L24) | +| `AgentCard` | AgentCard - render `` for the default card, or compose `AgentCard.Header` / `Reasoning` / `Tools` / `Body` for a custom layout. Mirrors the `ToolCall` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/agent-card.tsx#L270) | +| `AgentPicker` | AgentPicker - render `` for the default data-driven combobox, or compose `AgentPicker.Trigger`, `Content`, `Search`, `List`, `Item`, `Create`, and `Manage` for a custom menu. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/agent-picker.tsx#L420) | +| `AppShell` | Compound AppShell. Compose: | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/app-shell.tsx#L628) | +| `AttachmentPill` | AttachmentPill - render `` for the default chip, or compose `AttachmentPill.Root` + `.Thumbnail` / `.Icon` / `.Label` / `.Retry` / `.Remove` for a custom layout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachment-pill.tsx#L495) | +| `AttachmentsPanel` | AttachmentsPanel - render `` for the default panel, or compose `AttachmentsPanel.Root` + `List` / `Item` / `Empty` / `Action` for a custom layout. Mirrors the `ToolCall` / `Sources` compounds: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachments-panel.tsx#L650) | +| `BranchPicker` | Branch picker with addressable previous, count, and next leaves. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/branch-picker.tsx#L192) | +| `Chat` | Render chat components through the preset or its composable sub-parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/chat-preset.tsx#L37) | +| `ChatActions` | ChatActions - render `` for the default preset menu, or compose `ChatActions.Trigger` / `Content` / `Item` (each reads `useChatActions()`) for a custom menu. Mirrors the `ToolCall` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-actions.tsx#L197) | +| `ChatAgentPicker` | Render the connected agent switcher, or nothing when there's nothing to switch. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-agent-picker.tsx#L63) | +| `ChatContextProvider` | Render chat context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L106) | +| `ChatEmpty` | Render the chat empty state. It renders whatever it is given and never hides itself: in a custom layout, gate it on the thread being empty with ` ctx.isEmpty}>`. The `` preset does this for you. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty.tsx#L50) | +| `ChatEmptyState` | Compound empty state. Use the namespaced parts to compose the view: `Root`, `Avatar`, `Heading`, `Suggestions`, `Suggestion`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty-state.tsx#L169) | +| `ChatIf` | Render chat if. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-if.tsx#L19) | +| `ChatInput` | ChatInput - render `` for the default composer, or compose `ChatInput.Field` + `ChatInput.Send`/`Stop`/`Voice`/`Model`/`Attach`/`Export`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L335) | +| `ChatInputAttach` | Attachment `+` control. When attaching files is the only action, `+` opens the file dialog directly. When `onSelectAttachment` is also set it becomes a portalled `+` menu (Studio `PromptForm`'s `PlusMenu`) with "Add photos & files" and "Select document". | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-attach.tsx#L30) | +| `ChatInputContextProvider` | Provider for `` context (RFC 2980 name). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L87) | +| `ChatInputExport` | Download the supplied conversation as Markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L92) | +| `ChatInputField` | The multiline text editor. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L48) | +| `ChatInputModel` | Model selector - shows when models are configured. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L74) | +| `ChatInputRoot` | `ChatInput.Root` - the provider shell for a fully custom composer. Supplies `ChatInputContext` from props and renders your children, so you arrange `ChatInput.Field` + the toolbar sub-parts yourself (like `Message.Root`). The default `` is exactly this Root plus the standard body. Omitted props fall back to the surrounding `ChatContext` (``), so a propless Root wires itself to the shared session; explicit props win. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L151) | +| `ChatInputSend` | Send button, shown while the composer is idle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L40) | +| `ChatInputStop` | Stop button, shown while the composer is streaming. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L80) | +| `ChatInputSubmit` | Switch between send and stop states with one control. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-submit.tsx#L22) | +| `ChatInputToolbar` | `ChatInput.Toolbar` - a semantic layout slot for the composer's action row. Group/reorder the action sub-parts (`ChatInput.Attach`/`.Model`/`.Export`/ `.Voice`/`.Send`) inside it without re-implementing the composer. Pure layout: the children read their own `ChatInputContext`, so `` just mirrors the default action-row wrapper classes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L123) | +| `ChatInputVoice` | Voice button, shown when the idle composer is empty. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L118) | +| `ChatMessageList` | Render the default message list or compose its centered `Content` column. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-message-list.tsx#L264) | +| `ChatMessagesSkeleton` | Render the loading skeleton for a chat thread. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/chat-messages-skeleton.tsx#L41) | +| `ChatRoot` | Render chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L101) | +| `ChatSidebar` | Render a chat sidebar - usable as `` or `…`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L621) | +| `ChatThemeScope` | Wrap chat primitives in the `[data-vf-ui]` token scope so they're themed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-theme-scope.tsx#L30) | +| `CodeBlock` | Render escaped source or delegate to explicit syntax/diagram capabilities. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L270) | +| `CodeSurface` | Render through an explicit extension capability or escaped plain source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L239) | +| `ComposerContextProvider` | Render composer context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L70) | +| `CONVERSATION_STORAGE_LIMITS` | Explicit resource limits applied on both the read and write paths. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/persistence/conversation-codec.ts#L48) | +| `ConversationEmptyState` | State for conversation empty. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L71) | +| `ConversationsContextProvider` | Low-level context provider (value supplied by the caller). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/conversations-context.tsx#L45) | +| `ConversationScrollButton` | Render conversation scroll button. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L116) | +| `ConversationsProvider` | ConversationsProvider - calls `useConversations` once with your `store` / `id` / `onSelect` and shares it via `ConversationsContext`. Declare persistence + router wiring here, once, at the app layout; children read it with `useConversationsContext`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/conversations-context.tsx#L58) | +| `CopyButton` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L191) | +| `DEFAULT_CHAT_STREAM_IDLE_TIMEOUT_MS` | Default value for chat stream idle timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L13) | +| `DEFAULT_CHAT_STREAM_TOOL_RUNNING_TIMEOUT_MS` | Default value for chat stream tool running timeout ms. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/lifecycle/watchdog-compat-adapter.ts#L15) | +| `DropZoneOverlay` | Drag overlay shown over the composer while files are dragged onto it - the glyph-in-a-circle + "Drop files" from Studio's `PromptForm`. Rendered inside a `relative` card; fills it and blurs the content behind. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/drop-zone.tsx#L18) | +| `ErrorBanner` | Render error banner. Mirrors Studio's inline chat retry banner: an amber `warning` alert with a leading triangle icon and a small filled "Try again" button (not a low-emphasis error pill). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/error-banner.tsx#L38) | +| `FadeIn` | Render fade in. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L37) | +| `InferenceBadge` | Render inference badge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/inference-badge.tsx#L15) | +| `InlineCitation` | Render the default citation or compose its `Trigger` and `Card` parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/inline-citation.tsx#L308) | +| `Loader` | Render loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L14) | +| `Markdown` | Present Markdown source using an injected rich renderer or the explicit dependency-free plain-source contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/markdown.tsx#L175) | +| `Message` | Message - render `` for the default turn, or compose `Message.Root` + `Message.Header`/`Content`/`Actions`/… for a custom layout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/message.tsx#L775) | +| `MessageActionBar` | Context-free message actions with addressable `Copy`, `Copied`, `Regenerate`, and `Edit` icon leaves. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/message-actions.tsx#L211) | +| `MessageContextProvider` | Render message context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/message-context.tsx#L123) | +| `MessageEditForm` | Render message edit form. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/message-edit-form.tsx#L18) | +| `MessageFeedback` | Message feedback with addressable positive and negative action leaves. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/message-feedback.tsx#L168) | +| `ModelAvatar` | Render model avatar. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/model-avatar.tsx#L70) | +| `ModelSelector` | ModelSelector - render `` for the default data-driven combobox, or compose `ModelSelector.Trigger`, `Content`, `Search`, `List`, and `Item` for a custom menu. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/model-selector.tsx#L446) | +| `QuickActions` | Render quick actions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/quick-actions.tsx#L20) | +| `Reasoning` | Reasoning - render `` for the default disclosure, or compose `Reasoning.Trigger` + `Reasoning.Content` for a custom layout. Mirrors the `Message` / `ToolCall` compounds: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/reasoning.tsx#L233) | +| `RichCodeBlock` | Render rich code block. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/code-block.tsx#L25) | +| `Shimmer` | Render shimmer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/animations.tsx#L5) | +| `SkillBadge` | Render skill badge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/skill-badge.tsx#L23) | +| `SourcePill` | Render a single source pill with hover preview and score-color behaviour. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sources.tsx#L165) | +| `Sources` | Sources - render `` for the default row, or compose `Sources.Root` + `Sources.List` + `Sources.Pill` for a custom layout. Mirrors the `ToolCall` / `Reasoning` compounds: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sources.tsx#L225) | +| `StepIndicator` | StepIndicator - render `` for the default divider, or compose `StepIndicator.Root` + `.Rule` / `.Label` for a custom layout. Mirrors the `ToolCall` / `Sources` compounds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/step-indicator.tsx#L141) | +| `Suggestion` | Render suggestion. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L14) | +| `Suggestions` | Render suggestions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/empty-state.tsx#L49) | +| `Tabs` | Tablist container - delegates selection to the adapter, owns the size + look. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/tabs.tsx#L66) | +| `TabsItem` | Individual tab - renders as a button, or an anchor when `href` is set. The adapter's `tabs.Tab` supplies the `role="tab"` / `aria-selected` / `data-state` mechanics and composes selection onto the caller's `onClick` (caller's runs first, then the tab activates); this skin renders the visual pill and forwards native props/ref. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/tabs.tsx#L108) | +| `TabSwitcher` | Render tab switcher. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tab-switcher.tsx#L28) | +| `ToolCall` | ToolCall - render `` for the default card. `invoke_agent` calls use a child-agent card by default. Pass children to replace the default, or compose `ToolCall.Trigger` / `Body` / `Input` / `Output` / `Error` for a custom layout. Mirrors the `Message` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tool-ui.tsx#L913) | +| `ToolStatusBadge` | Render tool status badge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/tool-ui.tsx#L100) | ### Functions @@ -348,8 +348,8 @@ Result returned from use agent. | `ChatInputExportProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L121) | | `ChatInputFieldProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L18) | | `ChatInputModelProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L105) | -| `ChatInputProps` | Props accepted by `ChatInput`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L142) | -| `ChatInputRootProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L216) | +| `ChatInputProps` | Props accepted by `ChatInput`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L149) | +| `ChatInputRootProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L223) | | `ChatInputSendProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L77) | | `ChatInputSlottedActionProps` | Literal slotted action contract with an element-specific ref and event. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L55) | | `ChatInputSlottedSubmitProps` | Literal slotted props for the unified `ChatInputSubmit` control. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.types.ts#L97) | @@ -366,7 +366,7 @@ Result returned from use agent. | `ChatMessagesSkeletonProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/chat-messages-skeleton.tsx#L36) | | `ChatProps` | Props accepted by chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/chat-props.ts#L34) | | `ChatReasoningPart` | Chat message part that carries reasoning text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L18) | -| `ChatRootProps` | Props accepted by chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L31) | +| `ChatRootProps` | Props accepted by chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L38) | | `ChatSidebarComponent` | Compound type - the preset plus its namespaced sub-components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L611) | | `ChatSidebarEmptyProps` | Props accepted by `ChatSidebarEmpty`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.types.ts#L132) | | `ChatSidebarGroupProps` | Props accepted by `ChatSidebarGroup`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.types.ts#L122) | diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx new file mode 100644 index 0000000000..d442302bfe --- /dev/null +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -0,0 +1,372 @@ +/** + * One shared chat context (issue veryfront-issue-inbox#69): `ChatInput.Root` + * (via `useComposerValue`) falls back to the surrounding `ChatContext` when its + * explicit props are omitted, so `` wires the + * composer without re-threading the session. Explicit props always win, and a + * standalone `ChatInput.Root` (own props, no `Chat.Root`) is unchanged. + */ +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import type { FormEvent } from "react"; +import { JSDOM } from "npm:jsdom@28.0.0"; +import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; +import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { UseChatResult } from "#veryfront/agent/react"; +import { Chat } from "../chat-preset.tsx"; +import { ChatInput } from "./chat-composer.tsx"; +import { useChatContext } from "../contexts/chat-context.tsx"; +import type { ChatContextValue } from "../contexts/chat-context.tsx"; + +function makeChat(overrides: Partial = {}): UseChatResult { + return { + messages: [], + input: "", + isLoading: false, + status: "ready", + streamingMessageId: null, + error: null, + model: undefined, + activeModel: undefined, + inferenceMode: "cloud", + setInput: () => {}, + setModel: () => {}, + sendMessage: () => Promise.resolve(), + editMessage: () => Promise.resolve(), + getBranches: () => ({ current: 0, total: 1 }), + switchBranch: () => {}, + reload: () => Promise.resolve(), + stop: () => {}, + setMessages: () => {}, + addToolOutput: () => {}, + handleInputChange: () => {}, + handleSubmit: () => Promise.resolve(), + ...overrides, + }; +} + +function installDomGlobals(dom: JSDOM): () => void { + const window = dom.window; + const previous = { + window: globalThis.window, + document: globalThis.document, + navigator: globalThis.navigator, + self: globalThis.self, + Node: globalThis.Node, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + KeyboardEvent: globalThis.KeyboardEvent, + MouseEvent: globalThis.MouseEvent, + }; + + Object.assign(globalThis, { + window, + document: window.document, + navigator: window.navigator, + self: window, + Node: window.Node, + Element: window.Element, + HTMLElement: window.HTMLElement, + KeyboardEvent: window.KeyboardEvent, + MouseEvent: window.MouseEvent, + }); + + return () => { + Object.assign(globalThis, previous); + dom.window.close(); + }; +} + +describe("react/components/chat/chat/composition/chat-composer shared context", () => { + it("a propless ChatInput.Root reads the session input from the enclosing Chat.Root", () => { + const html = renderToString( + + + + + + , + ); + + assertStringIncludes( + html, + "draft from session", + "the field must show the shared session input without explicit props", + ); + }); + + it("a propless ChatInput.Submit dispatches through the shared session", async () => { + const dom = new JSDOM( + '
', + { url: "https://example.com/" }, + ); + const restore = installDomGlobals(dom); + let sessionSubmits = 0; + const chat = makeChat({ + input: "ready", + handleSubmit: (_e?: FormEvent) => { + sessionSubmits += 1; + return Promise.resolve(); + }, + }); + let root: Root | undefined; + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "Expected root element to exist"); + const createdRoot = createRoot(rootElement); + root = createdRoot; + flushSync(() => { + createdRoot.render( + + + + + + , + ); + }); + + const sendButton = document.querySelector('button[aria-label="Send"]'); + assert(sendButton, "Expected the send control to render from shared context"); + flushSync(() => sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + assertEquals(sessionSubmits, 1, "submit must dispatch through the shared session"); + } finally { + if (root) await unmountReactRoot(root); + restore(); + } + }); + + it("explicit ChatInput.Root props win over the surrounding context values", async () => { + const html = renderToString( + + {}}> + + + , + ); + assertStringIncludes(html, "explicit value", "the explicit input prop wins"); + assert( + !html.includes("context value"), + "the context input must not leak past an explicit prop", + ); + + const dom = new JSDOM( + '
', + { url: "https://example.com/" }, + ); + const restore = installDomGlobals(dom); + let sessionSubmits = 0; + let explicitSubmits = 0; + const chat = makeChat({ + input: "ready", + handleSubmit: () => { + sessionSubmits += 1; + return Promise.resolve(); + }, + }); + let root: Root | undefined; + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "Expected root element to exist"); + const createdRoot = createRoot(rootElement); + root = createdRoot; + flushSync(() => { + createdRoot.render( + + explicitSubmits += 1}> + + + + , + ); + }); + + const sendButton = document.querySelector('button[aria-label="Send"]'); + assert(sendButton, "Expected the send control to render"); + flushSync(() => sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + assertEquals(explicitSubmits, 1, "the explicit onSubmit prop wins"); + assertEquals(sessionSubmits, 0, "the session submit must not fire past an explicit prop"); + } finally { + if (root) await unmountReactRoot(root); + restore(); + } + }); + + it("a standalone ChatInput.Root with full explicit props behaves exactly as before", async () => { + const html = renderToString( + {}} + onSubmit={() => {}} + models={[{ value: "model-1", label: "Model One" }]} + model="model-1" + onModelChange={() => {}} + attachments={[]} + onRemoveAttachment={() => {}} + stop={() => {}} + onAttach={() => {}} + > + + + , + ); + assertStringIncludes(html, "standalone draft", "standalone composer keeps its explicit input"); + + const dom = new JSDOM( + '
', + { url: "https://example.com/" }, + ); + const restore = installDomGlobals(dom); + let submits = 0; + let root: Root | undefined; + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "Expected root element to exist"); + const createdRoot = createRoot(rootElement); + root = createdRoot; + flushSync(() => { + createdRoot.render( + {}} + onSubmit={() => submits += 1} + models={[{ value: "model-1", label: "Model One" }]} + model="model-1" + onModelChange={() => {}} + attachments={[]} + onRemoveAttachment={() => {}} + stop={() => {}} + onAttach={() => {}} + > + + + , + ); + }); + + const sendButton = document.querySelector('button[aria-label="Send"]'); + assert(sendButton, "Expected the standalone send control to render"); + flushSync(() => sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + assertEquals(submits, 1, "standalone submit stays caller-owned"); + } finally { + if (root) await unmountReactRoot(root); + restore(); + } + }); + + it("the batteries ChatInput inherits the session loading state when isLoading is omitted", () => { + const html = renderToString( + + + , + ); + + assertStringIncludes( + html, + 'aria-label="Stop"', + "an omitted isLoading must inherit the streaming session, showing Stop", + ); + assert( + !html.includes('aria-label="Send"'), + "Send must stay hidden while the shared session is streaming", + ); + }); + + it("an explicit isLoading prop still wins over the streaming session", () => { + const html = renderToString( + + + , + ); + + assertStringIncludes(html, 'aria-label="Send"', "the explicit isLoading={false} prop wins"); + assert( + !html.includes('aria-label="Stop"'), + "the session loading state must not leak past an explicit isLoading prop", + ); + }); + + it("the batteries ChatInput renders attachments resolved from the shared context", () => { + const html = renderToString( + {}} + > + + , + ); + + assertStringIncludes( + html, + "notes.pdf", + "a context-resolved attachment must render its pill in the composer body", + ); + assertStringIncludes( + html, + 'aria-label="Remove notes.pdf"', + "the context-resolved onRemoveAttachment must surface the removal control", + ); + }); + + it("an explicit null flat prop overrides the session value instead of inheriting it", () => { + let observed: ChatContextValue | undefined; + function ContextProbe(): null { + observed = useChatContext(); + return null; + } + + renderToString( + + + , + ); + + assert(observed, "Expected the probe to observe the chat context"); + assertEquals(observed.error, null, "an explicit error={null} must clear the session error"); + assertEquals( + observed.streamingMessageId, + null, + "an explicit streamingMessageId={null} must clear the session streaming id", + ); + }); + + it("an omitted flat prop still inherits the session error and streaming id", () => { + let observed: ChatContextValue | undefined; + function ContextProbe(): null { + observed = useChatContext(); + return null; + } + + const sessionError = new Error("session failure"); + renderToString( + + + , + ); + + assert(observed, "Expected the probe to observe the chat context"); + assertEquals(observed.error, sessionError, "an omitted error prop inherits the session error"); + assertEquals( + observed.streamingMessageId, + "msg-1", + "an omitted streamingMessageId prop inherits the session streaming id", + ); + }); +}); diff --git a/src/react/components/chat/chat/composition/chat-composer.tsx b/src/react/components/chat/chat/composition/chat-composer.tsx index 80b6a73ae0..155b4418d0 100644 --- a/src/react/components/chat/chat/composition/chat-composer.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.tsx @@ -144,7 +144,9 @@ ChatInputToolbar.displayName = "ChatInput.Toolbar"; * `ChatInput.Root` — the provider shell for a fully custom composer. Supplies * `ChatInputContext` from props and renders your children, so you arrange * `ChatInput.Field` + the toolbar sub-parts yourself (like `Message.Root`). The - * default `` is exactly this Root plus the standard body. + * default `` is exactly this Root plus the standard body. Omitted + * props fall back to the surrounding `ChatContext` (``), + * so a propless Root wires itself to the shared session; explicit props win. */ export function ChatInputRoot( { className, children, ref, ...state }: ChatInputRootProps, @@ -152,7 +154,7 @@ export function ChatInputRoot( const baseCtxValue = useComposerValue(state); const { contextValue, fileInput } = useChatInputAttachmentPicker( baseCtxValue, - state.onAttach, + baseCtxValue.onAttach, state.attachAccept, ); return ( @@ -173,7 +175,10 @@ function ChatInputBase( onChange, setInput, onSubmit, - isLoading = false, + // No `= false` default: an omitted `isLoading` must stay `undefined` so + // `useComposerValue` can inherit the surrounding session's loading state + // (a default would always win over the context and hide Stop mid-turn). + isLoading, placeholder = "Type a message...", theme, stop, @@ -215,13 +220,6 @@ function ChatInputBase( ); const handleAttach = withFocus(onAttach); - const { - isDragActive, - onDragEnter, - onDragLeave, - onDragOver, - onDrop: onFileDrop, - } = useDropZone(withFocus(onDrop ?? onAttach)); const baseCtxValue = useComposerValue({ input, onChange, @@ -241,9 +239,19 @@ function ChatInputBase( attachments, onRemoveAttachment, }); + // Attach + drop fall back to the context-resolved handler (like + // `ChatInput.Root`), so a propless composer inside `` + // keeps a working picker and drop zone. Explicit props stay focus-wrapped. + const { + isDragActive, + onDragEnter, + onDragLeave, + onDragOver, + onDrop: onFileDrop, + } = useDropZone(withFocus(onDrop ?? onAttach) ?? baseCtxValue.onAttach); const { contextValue, fileInput } = useChatInputAttachmentPicker( baseCtxValue, - handleAttach, + baseCtxValue.onAttach, attachAccept, ); @@ -276,13 +284,13 @@ function ChatInputBase( /* Pending attachments — inside the composer card, above the editor (Studio PromptForm), not floating as a separate row. */ } - {attachments && attachments.length > 0 && ( + {contextValue.attachments.length > 0 && (
- {attachments.map((file) => ( + {contextValue.attachments.map((file) => ( ))} diff --git a/src/react/components/chat/chat/composition/chat-composer.types.ts b/src/react/components/chat/chat/composition/chat-composer.types.ts index 1e42f59a30..1213c580f0 100644 --- a/src/react/components/chat/chat/composition/chat-composer.types.ts +++ b/src/react/components/chat/chat/composition/chat-composer.types.ts @@ -138,12 +138,19 @@ export interface ChatInputToolbarProps extends React.HTMLAttributes; } -/** Props accepted by `ChatInput`. */ +/** + * Props accepted by `ChatInput`. + * + * Every prop is optional: omitted state falls back to the surrounding + * `ChatContext` (``), so a propless `` wires + * itself to the shared session. Explicit props always win, and a standalone + * composer outside a `` still supplies its own `input`/`onChange`. + */ export interface ChatInputProps { - /** Current text value of the composer input (controlled). */ - input: string; - /** Fired as the user edits the input. */ - onChange: (e: React.ChangeEvent) => void; + /** Current text value of the composer input (controlled). Falls back to `ChatContext.input`. */ + input?: string; + /** Fired as the user edits the input. Falls back to `ChatContext.setInput`. */ + onChange?: (e: React.ChangeEvent) => void; /** * Update the controlled input value for headless consumers. The preset wires * this automatically. Direct consumers must provide it before calling diff --git a/src/react/components/chat/chat/composition/chat-root.tsx b/src/react/components/chat/chat/composition/chat-root.tsx index 65fb2216cf..8d5c855974 100644 --- a/src/react/components/chat/chat/composition/chat-root.tsx +++ b/src/react/components/chat/chat/composition/chat-root.tsx @@ -9,7 +9,7 @@ import * as React from "react"; import { ChatContainer } from "#veryfront/react/primitives/index.ts"; -import type { ChatMessage, ChatStatus } from "#veryfront/agent/react"; +import type { ChatMessage, ChatStatus, UseChatResult } from "#veryfront/agent/react"; import type { ChatTheme } from "../../theme.ts"; import { useDocumentNonce } from "../../../ui/csp-nonce.ts"; import { @@ -27,15 +27,29 @@ import type { BranchInfo } from "#veryfront/agent/react"; import { ChatContextProvider } from "../contexts/chat-context.tsx"; import type { ChatContextValue } from "../contexts/chat-context.tsx"; -/** Props accepted by chat root. */ +/** + * Props accepted by chat root. + * + * Supply either `chat` (a `useChat()` session) or the flat props + * (`messages`, `input`, `onSubmit`, …). Both are optional so the two modes can + * mix, but a `` given neither renders an empty chat whose + * `setInput`/`onSubmit` are inert no-ops. + */ export interface ChatRootProps extends Omit, "children"> { children: React.ReactNode; /** React 19: ref is a regular prop. */ ref?: React.Ref; + /** + * Drive the chat surface from a `useChat()` session you own: + * ``. Folds the session state into the shared + * context; the flat props below stay as an explicit override path. + */ + chat?: UseChatResult; + // Messages - messages: ChatMessage[]; + messages?: ChatMessage[]; isLoading?: boolean; /** Streaming lifecycle of the current turn (`useChat().status`). */ status?: ChatStatus; @@ -44,7 +58,7 @@ export interface ChatRootProps extends Omit error?: Error | null; // Input - input: string; + input?: string; setInput?: (value: string) => void; // Submit / Stop @@ -87,26 +101,27 @@ export interface ChatRootProps extends Omit export function ChatRoot( { children, - messages, - isLoading = false, - status, - streamingMessageId, - error = null, - input, - setInput, - onSubmit, - onStop, - onReload, - model, + chat, + messages: messagesProp, + isLoading: isLoadingProp, + status: statusProp, + streamingMessageId: streamingMessageIdProp, + error: errorProp, + input: inputProp, + setInput: setInputProp, + onSubmit: onSubmitProp, + onStop: onStopProp, + onReload: onReloadProp, + model: modelProp, models = [], - onModelChange, + onModelChange: onModelChangeProp, agent, attachments = [], onAttach, onRemoveAttachment, - editMessage, - getBranches, - switchBranch, + editMessage: editMessageProp, + getBranches: getBranchesProp, + switchBranch: switchBranchProp, onFeedback, onSourceClick, theme: userTheme, @@ -117,6 +132,28 @@ export function ChatRoot( ...containerProps }: ChatRootProps, ): React.ReactElement { + // `chat` folds the session's flat state into the context; each explicit flat + // prop wins over the session value (issue #69's override path). + const messages = messagesProp ?? chat?.messages ?? []; + const isLoading = isLoadingProp ?? chat?.isLoading ?? false; + const status = statusProp ?? chat?.status; + // Nullable props compare against `undefined`, not nullish: `error={null}` and + // `streamingMessageId={null}` are explicit overrides that clear the session + // value, so `??` would wrongly restore it from `chat`. + const streamingMessageId = streamingMessageIdProp !== undefined + ? streamingMessageIdProp + : chat?.streamingMessageId; + const error = errorProp !== undefined ? errorProp : (chat?.error ?? null); + const input = inputProp ?? chat?.input ?? ""; + const setInput = setInputProp ?? chat?.setInput; + const onSubmit = onSubmitProp ?? chat?.handleSubmit; + const onStop = onStopProp ?? chat?.stop; + const onReload = onReloadProp ?? chat?.reload; + const model = modelProp ?? chat?.model; + const onModelChange = onModelChangeProp ?? chat?.setModel; + const editMessage = editMessageProp ?? chat?.editMessage; + const getBranches = getBranchesProp ?? chat?.getBranches; + const switchBranch = switchBranchProp ?? chat?.switchBranch; const theme = React.useMemo(() => mergeThemes(defaultChatTheme, userTheme), [userTheme]); const nonce = useDocumentNonce(); const tokenCSS = React.useMemo(() => generateTokenCSS(), []); diff --git a/src/react/components/chat/chat/composition/use-composer-value.ts b/src/react/components/chat/chat/composition/use-composer-value.ts index 23877dfb9c..3e5e8998f7 100644 --- a/src/react/components/chat/chat/composition/use-composer-value.ts +++ b/src/react/components/chat/chat/composition/use-composer-value.ts @@ -7,6 +7,7 @@ import * as React from "react"; import type { ChatFilePart } from "#veryfront/agent/react"; +import { useChatContextOptional } from "../contexts/chat-context.tsx"; import type { ChatInputContextValue } from "../contexts/composer-context.tsx"; import type { ModelOption } from "../../model-selector.tsx"; import type { AttachmentInfo } from "../components/attachment-pill.tsx"; @@ -14,8 +15,10 @@ import { attachmentsToFileParts, hasPendingAttachments } from "../chat-attachmen /** State shared by controlled and composer-owned submit modes. */ interface ComposerStateBaseProps { - input: string; - onChange: ( + /** Falls back to the surrounding `ChatContext` input when omitted. */ + input?: string; + /** Falls back to `ChatContext.setInput` when omitted. */ + onChange?: ( e: React.ChangeEvent, ) => void; /** Clear pending attachments after a composer-owned submit sends. */ @@ -69,7 +72,33 @@ function missingSetInput(): never { ); } -export function useComposerValue(p: ComposerStateProps): ChatInputContextValue { +export function useComposerValue(props: ComposerStateProps): ChatInputContextValue { + // One shared chat context (issue #69): when a `` is above, + // omitted props fall back to its `ChatContext` (the shared session), so a + // propless `` wires itself. Explicit props always win, and a + // standalone composer (no ChatContext) keeps the props-only behavior. + const chat = useChatContextOptional(); + const chatSetInput = chat?.setInput; + const fallbackOnChange = React.useCallback( + (e: React.ChangeEvent) => + chatSetInput?.(e.target.value), + [chatSetInput], + ); + const p = { + ...props, + input: props.input ?? chat?.input ?? "", + onChange: props.onChange ?? fallbackOnChange, + setInput: props.setInput ?? chat?.setInput, + onSubmit: props.onSubmit ?? chat?.onSubmit, + isLoading: props.isLoading ?? chat?.isLoading, + stop: props.stop ?? chat?.onStop, + model: props.model ?? chat?.model, + models: props.models ?? chat?.models, + onModelChange: props.onModelChange ?? chat?.onModelChange, + attachments: props.attachments ?? chat?.attachments, + onAttach: props.onAttach ?? chat?.onAttach, + onRemoveAttachment: props.onRemoveAttachment ?? chat?.onRemoveAttachment, + }; const hasResolvedAttachment = p.attachments?.some((attachment) => Boolean(attachment.url) && attachment.state !== "uploading" && diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index 49894794c2..32f0b53337 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -1047,6 +1047,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "64px).", "65,536.", "65_536,", + "69's", + "69):", "6;", "6FB57C", "6v14a2", @@ -2226,7 +2228,10 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ChatContainerProps", "ChatContainerProps>(", "ChatContext", + "ChatContext)", "ChatContext.Provider;", + "ChatContext.input", + "ChatContext.setInput", "ChatContextProvider", "ChatContextProvider,", "ChatContextProvider>", @@ -3715,6 +3720,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Focus-trapped", "FocusEvent)", "Fold", + "Folds", "Follow", "Follows", "Footer", @@ -4835,6 +4841,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Notifications", "Notified", "November", + "Nullable", "Number", "Number(declaredLength);", "Number(draft.trim());", @@ -4948,6 +4955,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "Omit,", "Omit", + "chat={", "chat={chat}", "chat={props.chat}", "chat={useChat()}", "chat={useChat()}>", "chat?.agent?.avatarUrl", "chat?.agent?.name", + "chat?.attachments,", "chat?.editMessage;", + "chat?.error", "chat?.getBranches;", + "chat?.handleSubmit;", + "chat?.input", + "chat?.isLoading", + "chat?.isLoading,", + "chat?.messages", + "chat?.model,", + "chat?.model;", + "chat?.models,", + "chat?.onAttach,", "chat?.onFeedback;", + "chat?.onModelChange,", "chat?.onReload;", + "chat?.onRemoveAttachment,", "chat?.onSourceClick}", + "chat?.onStop,", + "chat?.onSubmit,", + "chat?.reload;", + "chat?.setInput,", + "chat?.setInput;", + "chat?.setModel;", "chat?.status", "chat?.status,", + "chat?.status;", + "chat?.stop;", "chat?.streamingMessageId", + "chat?.streamingMessageId;", "chat?.switchBranch;", "chat?.theme.message?.assistant", "chat?.theme.message?.user", @@ -9519,6 +9552,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "chatButtonVariants,", "chatContainerVariants", "chatContainerVariants,", + "chatSetInput", + "chatSetInput?.(e.target.value),", "chatStatus", "chatStatus:", "chatTokens", @@ -9985,6 +10020,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "common", "compact", "companion", + "compare", "compat", "compatibility", "compatibility.", @@ -10289,6 +10325,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "context-bound", "context-free", "context-menu.tsx", + "context-resolved", "context.", "context.activeConversation", "context.activeConversation.id", @@ -10373,6 +10410,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "contextOwnsId,", "contextValue", "contextValue,", + "contextValue.attachments.length", + "contextValue.attachments.map((file)", "contextValue:", "contextmenu", "contexts", @@ -11818,6 +11857,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "editMessage:", "editMessage={editMessage}", "editMessage?:", + "editMessageProp", + "editMessageProp,", "editValue.trim();", "edited.", "editing)", @@ -12098,6 +12139,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "error:", "error;", "error={error}", + "error={null}", "error?:", "errorId", "errorId,", @@ -12110,6 +12152,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "errorPresent", "errorPresent,", "errorPresent:", + "errorProp", + "errorProp,", "errorText", "errorText,", "errorText:", @@ -12389,6 +12433,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "fallbackHeadingId", "fallbackIds,", "fallbackIds:", + "fallbackOnChange", + "fallbackOnChange,", "fallbackResponse", "fallbackResponse;", "fallbacks", @@ -12642,6 +12688,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "focus-visible:ring-offset-2", "focus-visible:ring-offset-[var(--background)]", "focus-within:opacity-100", + "focus-wrapped.", "focus.", "focus/dismiss", "focus:bg-[var(--tertiary)]", @@ -12702,6 +12749,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "focuses", "focusin", "fold", + "folds", "follow", "following,", "follows", @@ -12885,6 +12933,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "getBranches={getBranches}", "getBranches?.(message.id)", "getBranches?:", + "getBranchesProp", + "getBranchesProp,", "getChatInputActionType(", "getChatInputActionType,", "getChatTokensCSS", @@ -13826,6 +13876,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "inputMode,", "inputMode=", "inputMode={resolvedInputMode}", + "inputProp", + "inputProp,", "inputProps", "inputProps}", "inputRef", @@ -14108,6 +14160,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "isLoading={Boolean(isLoading)}", "isLoading={isLoading}", "isLoading?:", + "isLoadingProp", + "isLoadingProp,", "isMessageCreatedAtPath(path:", "isMessageStreaming)", "isMessageStreaming,", @@ -14199,6 +14253,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "isn't", "isolated", "isolation", + "issue", "issued", "it", "it's", @@ -15333,6 +15388,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "messages:", "messages={messages}", "messages?:", + "messagesProp", + "messagesProp,", "messages[messages.length", "metadata", "metadata.", @@ -15354,6 +15411,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "microtask", "mid-load:", "mid-stream.", + "mid-turn).", "midnight.", "migration", "migration.", @@ -15414,6 +15472,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "mistake", "mistake:", "mistaken", + "mix,", "ml-0.5", "ml-auto", "mobile", @@ -15487,6 +15546,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "model?.value.split(", "model?:", "modelId:", + "modelProp", + "modelProp,", "modelValue:", "modeled", "modelled", @@ -15508,6 +15569,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "models?:", "models[0];", "model}", + "modes", "modes.", "modified:", "module", @@ -15782,6 +15844,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "no-explicit-any", "no-files", "no-op", + "no-ops.", "no-persistence)", "no-referrer", "no-state)", @@ -15915,6 +15978,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "null>;", "null>>", "null>>;", + "nullish:", "null}", "num", "num(u.inputTokens),", @@ -16011,7 +16075,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onAttach", "onAttach(e.target.files);", "onAttach(event.target.files);", - "onAttach));", + "onAttach)", "onAttach,", "onAttach:", "onAttach={effectiveOnAttach}", @@ -16289,10 +16353,13 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onManage?.();", "onManage?:", "onManage]);", + "onModelChange", "onModelChange,", "onModelChange:", "onModelChange={onModelChange}", "onModelChange?:", + "onModelChangeProp", + "onModelChangeProp,", "onMouseDown,", "onMouseDown={(event)", "onMouseDown?.(event);", @@ -16382,11 +16449,14 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onRegenerate],", "onReload", "onReload,", + "onReload:", "onReload={reload}", "onReload?:", + "onReloadProp", + "onReloadProp,", "onRemove", "onRemove,", - "onRemove={onRemoveAttachment}", + "onRemove={contextValue.onRemoveAttachment}", "onRemove?.(attachment.id)}", "onRemove?:", "onRemoveAttachment", @@ -16453,11 +16523,14 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onSourceClick={onSourceClick", "onSourceClick={onSourceClick}", "onSourceClick?:", + "onStop", "onStop();", "onStop,", "onStop:", "onStop={stop}", "onStop?:", + "onStopProp", + "onStopProp,", "onSubmit", "onSubmit();", "onSubmit()}", @@ -16474,6 +16547,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onSubmit?:", "onSubmitEffective", "onSubmitEffective,", + "onSubmitProp", + "onSubmitProp,", "onSuggestionClick", "onSuggestionClick,", "onSuggestionClick={onSuggestionClick}", @@ -16669,6 +16744,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "optionLabels]);", "optional", "optional.", + "optional:", "optionalBoolean(record,", "optionalBoolean(record:", "optionalBoolean(value,", @@ -17132,6 +17208,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "patched", "path", "path))", + "path).", "path);", "path,", "path.", @@ -17655,14 +17732,17 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "propertyPath),", "propertyPath);", "propertyPath,", + "propless", "props", "props)", "props**", "props,", "props-based", + "props-only", "props.", "props.activeId", "props.asChild", + "props.attachments", "props.chat", "props.children))", "props.children.props.id;", @@ -17670,13 +17750,24 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "props.conversations", "props.id", "props.id;", + "props.input", + "props.isLoading", + "props.model", + "props.models", + "props.onAttach", + "props.onChange", "props.onDelete", + "props.onModelChange", "props.onNew", + "props.onRemoveAttachment", "props.onRename", "props.onSelect", + "props.onSubmit", "props.ref", "props.renderItem;", + "props.setInput", "props.skeleton", + "props.stop", "props/ref.", "props:", "props;", @@ -19381,8 +19472,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "serverNonce;", "session", "session's", + "session)", + "session),", "session,", "session.", + "session;", "sessionChat,", "sessionChat:", "sessionKey", @@ -19520,6 +19614,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "setInput={setValue}", "setInput?.(", "setInput?:", + "setInputProp", + "setInputProp,", "setInstructionsOpen(phase", "setInstructionsOpen]", "setInternal((current)", @@ -20239,7 +20335,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "state.maxBytes", "state.maxBytes}", "state.nodes", - "state.onAttach,", "state.toast,", "state.toast;", "state.toast],", @@ -20289,6 +20384,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "status?:", "statusPresentation(", "statusPresentation(status);", + "statusProp", + "statusProp,", "statusProps", "statusProps}", "stay", @@ -20404,8 +20501,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "streamingMessageId", "streamingMessageId,", "streamingMessageId:", + "streamingMessageId={null}", "streamingMessageId={streamingMessageId}", "streamingMessageId?:", + "streamingMessageIdProp", + "streamingMessageIdProp,", "streams", "stretched", "strict", @@ -20639,6 +20739,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "switchBranch:", "switchBranch={switchBranch}", "switchBranch?:", + "switchBranchProp", + "switchBranchProp,", "switchThumbVariants", "switchTrackVariants", "switchTrackVariants,", @@ -21994,7 +22096,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "useComposerContextOptional;", "useComposerContext]", "useComposerValue", - "useComposerValue(p:", + "useComposerValue(props:", "useComposerValue(state);", "useComposerValue({", "useConversation", @@ -22667,8 +22769,11 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "will", "will.", "win", + "win,", + "win.", "window", "window.open.", + "wins", "wins,", "wins.", "wins;", @@ -22734,6 +22839,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "writers", "writes", "wrong", + "wrongly", "x)", "x,", "x-axis", @@ -22806,4 +22912,5 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "zero-size", "zero-size,", "zone", + "zone.", ]; From ecde9ca798919d5ab0ccc84cb87e1f91011e4196 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 20:56:38 +0200 Subject: [PATCH 2/9] fix(chat): submit inherited attachments --- docs/api-reference/veryfront/chat.md | 4 +- .../chat-composer.shared-context.test.tsx | 65 +++++++++++++++++++ .../chat/chat/composition/chat-root.tsx | 19 +++++- .../dev/framework-candidates.generated.ts | 8 ++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 088bf17b1c..a5a5b16848 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -167,7 +167,7 @@ Result returned from use agent. | `ChatInputVoice` | Voice button, shown when the idle composer is empty. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-actions.tsx#L118) | | `ChatMessageList` | Render the default message list or compose its centered `Content` column. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-message-list.tsx#L264) | | `ChatMessagesSkeleton` | Render the loading skeleton for a chat thread. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/chat-messages-skeleton.tsx#L41) | -| `ChatRoot` | Render chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L101) | +| `ChatRoot` | Render chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L102) | | `ChatSidebar` | Render a chat sidebar - usable as `` or `…`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L621) | | `ChatThemeScope` | Wrap chat primitives in the `[data-vf-ui]` token scope so they're themed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-theme-scope.tsx#L30) | | `CodeBlock` | Render escaped source or delegate to explicit syntax/diagram capabilities. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/ui/code-block.tsx#L270) | @@ -366,7 +366,7 @@ Result returned from use agent. | `ChatMessagesSkeletonProps` | Props accepted by ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/chat-messages-skeleton.tsx#L36) | | `ChatProps` | Props accepted by chat. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/chat-props.ts#L34) | | `ChatReasoningPart` | Chat message part that carries reasoning text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/protocol.ts#L18) | -| `ChatRootProps` | Props accepted by chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L38) | +| `ChatRootProps` | Props accepted by chat root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-root.tsx#L39) | | `ChatSidebarComponent` | Compound type - the preset plus its namespaced sub-components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L611) | | `ChatSidebarEmptyProps` | Props accepted by `ChatSidebarEmpty`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.types.ts#L132) | | `ChatSidebarGroupProps` | Props accepted by `ChatSidebarGroup`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.types.ts#L122) | diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index d442302bfe..dbd073bafd 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -139,6 +139,71 @@ describe("react/components/chat/chat/composition/chat-composer shared context", } }); + it("submits context attachments through the shared session", async () => { + const dom = new JSDOM( + '
', + { url: "https://example.com/" }, + ); + const restore = installDomGlobals(dom); + let submitted: Parameters[0] | undefined; + let textOnlySubmits = 0; + const chat = makeChat({ + sendMessage: (message) => { + submitted = message; + return Promise.resolve(); + }, + handleSubmit: () => { + textOnlySubmits += 1; + return Promise.resolve(); + }, + }); + let root: Root | undefined; + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "Expected root element to exist"); + const createdRoot = createRoot(rootElement); + root = createdRoot; + flushSync(() => { + createdRoot.render( + + + + + + , + ); + }); + + const sendButton = document.querySelector('button[aria-label="Send"]'); + assert(sendButton, "Expected an attachment-only send control to render"); + flushSync(() => sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + assertEquals(textOnlySubmits, 0, "attachment submission must bypass text-only handleSubmit"); + assertEquals(submitted, { + text: "", + files: [{ + type: "file", + mediaType: "application/pdf", + url: "https://example.com/notes.pdf", + filename: "notes.pdf", + }], + }); + } finally { + if (root) await unmountReactRoot(root); + restore(); + } + }); + it("explicit ChatInput.Root props win over the surrounding context values", async () => { const html = renderToString( diff --git a/src/react/components/chat/chat/composition/chat-root.tsx b/src/react/components/chat/chat/composition/chat-root.tsx index 8d5c855974..f1679af9aa 100644 --- a/src/react/components/chat/chat/composition/chat-root.tsx +++ b/src/react/components/chat/chat/composition/chat-root.tsx @@ -26,6 +26,7 @@ import type { Source } from "../components/sources.tsx"; import type { BranchInfo } from "#veryfront/agent/react"; import { ChatContextProvider } from "../contexts/chat-context.tsx"; import type { ChatContextValue } from "../contexts/chat-context.tsx"; +import { attachmentsToFileParts, hasPendingAttachments } from "../chat-attachments.ts"; /** * Props accepted by chat root. @@ -146,7 +147,23 @@ export function ChatRoot( const error = errorProp !== undefined ? errorProp : (chat?.error ?? null); const input = inputProp ?? chat?.input ?? ""; const setInput = setInputProp ?? chat?.setInput; - const onSubmit = onSubmitProp ?? chat?.handleSubmit; + const submitSession = React.useCallback((e?: React.FormEvent) => { + if (!chat) return; + if (hasPendingAttachments(attachments)) { + e?.preventDefault(); + return; + } + const files = attachmentsToFileParts(attachments); + if (files.length === 0) return chat.handleSubmit(e); + + e?.preventDefault(); + chat.setInput(""); + for (const attachment of attachments) { + if (attachment.url) onRemoveAttachment?.(attachment.id); + } + return chat.sendMessage({ text: input.trim(), files }); + }, [attachments, chat, input, onRemoveAttachment]); + const onSubmit = onSubmitProp ?? (chat ? submitSession : undefined); const onStop = onStopProp ?? chat?.stop; const onReload = onReloadProp ?? chat?.reload; const model = modelProp ?? chat?.model; diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index 32f0b53337..3b7c652494 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -86,6 +86,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!cancelled", "!candidate.matches(", "!cardVisible", + "!chat)", "!child)", "!childTool)", "!complete", @@ -7402,6 +7403,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[api,", "[aria-hidden=", "[attachChildRef,", + "[attachments,", "[avatarSrc]);", "[b])", "[background-position:100%_center]", @@ -9478,6 +9480,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "chat.getBranches(messageId)", "chat.handleInputChange(event);", "chat.handleSubmit()", + "chat.handleSubmit(e);", "chat.handleSubmit}", "chat.input.trim();", "chat.isLoading)", @@ -9518,7 +9521,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "chat?.editMessage;", "chat?.error", "chat?.getBranches;", - "chat?.handleSubmit;", "chat?.input", "chat?.isLoading", "chat?.isLoading,", @@ -13322,6 +13324,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "hasOwn(value,", "hasPendingAttachment", "hasPendingAttachments", + "hasPendingAttachments(attachments))", "hasPendingAttachments(items:", "hasPendingAttachments(p.attachments", "hasPendingAttachments(submittedAttachments))", @@ -16464,7 +16467,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onRemoveAttachment:", "onRemoveAttachment={effectiveOnRemove}", "onRemoveAttachment={manageAttachments", + "onRemoveAttachment?.(attachment.id);", "onRemoveAttachment?:", + "onRemoveAttachment]);", "onRemoveUpload", "onRemoveUpload(attachment.id)}", "onRemoveUpload(file.id)}", @@ -20605,6 +20610,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "submit:", "submit={manageAttachments", "submit?:", + "submitSession", "submitWithAttachments", "submits", "submitted", From db0fb37ba82d256e8c45f12df5165162a9e7b76d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 21:12:10 +0200 Subject: [PATCH 3/9] fix(chat): submit resolved flat input --- .../chat-composer.shared-context.test.tsx | 55 +++++++++++++++++++ .../chat/chat/composition/chat-root.tsx | 14 +++-- .../dev/framework-candidates.generated.ts | 7 ++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index dbd073bafd..01ed62c7cb 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -204,6 +204,61 @@ describe("react/components/chat/chat/composition/chat-composer shared context", } }); + it("submits the resolved flat input instead of the session draft", async () => { + const dom = new JSDOM( + '
', + { url: "https://example.com/" }, + ); + const restore = installDomGlobals(dom); + let submitted: Parameters[0] | undefined; + let textOnlySubmits = 0; + let clearedInput: string | undefined; + const chat = makeChat({ + input: "session draft", + sendMessage: (message) => { + submitted = message; + return Promise.resolve(); + }, + handleSubmit: () => { + textOnlySubmits += 1; + return Promise.resolve(); + }, + }); + let root: Root | undefined; + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "Expected root element to exist"); + const createdRoot = createRoot(rootElement); + root = createdRoot; + flushSync(() => { + createdRoot.render( + clearedInput = value} + > + + + + + , + ); + }); + + const sendButton = document.querySelector('button[aria-label="Send"]'); + assert(sendButton, "Expected the send control to render for the flat draft"); + flushSync(() => sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + + assertEquals(textOnlySubmits, 0, "flat input submission must not use the session draft"); + assertEquals(submitted, { text: "flat draft" }); + assertEquals(clearedInput, ""); + } finally { + if (root) await unmountReactRoot(root); + restore(); + } + }); + it("explicit ChatInput.Root props win over the surrounding context values", async () => { const html = renderToString( diff --git a/src/react/components/chat/chat/composition/chat-root.tsx b/src/react/components/chat/chat/composition/chat-root.tsx index f1679af9aa..2a8a749f8a 100644 --- a/src/react/components/chat/chat/composition/chat-root.tsx +++ b/src/react/components/chat/chat/composition/chat-root.tsx @@ -147,6 +147,8 @@ export function ChatRoot( const error = errorProp !== undefined ? errorProp : (chat?.error ?? null); const input = inputProp ?? chat?.input ?? ""; const setInput = setInputProp ?? chat?.setInput; + const hasFlatSubmitState = inputProp !== undefined || setInputProp !== undefined || + isLoadingProp !== undefined; const submitSession = React.useCallback((e?: React.FormEvent) => { if (!chat) return; if (hasPendingAttachments(attachments)) { @@ -154,15 +156,19 @@ export function ChatRoot( return; } const files = attachmentsToFileParts(attachments); - if (files.length === 0) return chat.handleSubmit(e); + if (files.length === 0 && !hasFlatSubmitState) return chat.handleSubmit(e); e?.preventDefault(); - chat.setInput(""); + if (isLoading) return; + const text = input.trim(); + if (!text && files.length === 0) return; + + setInput?.(""); for (const attachment of attachments) { if (attachment.url) onRemoveAttachment?.(attachment.id); } - return chat.sendMessage({ text: input.trim(), files }); - }, [attachments, chat, input, onRemoveAttachment]); + return chat.sendMessage({ text, ...(files.length > 0 ? { files } : {}) }); + }, [attachments, chat, hasFlatSubmitState, input, isLoading, onRemoveAttachment, setInput]); const onSubmit = onSubmitProp ?? (chat ? submitSession : undefined); const onStop = onStopProp ?? chat?.stop; const onReload = onReloadProp ?? chat?.reload; diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index 3b7c652494..2d004000fc 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -176,6 +176,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "!h-auto", "!hasError", "!hasFinalText)", + "!hasFlatSubmitState)", "!hasInput", "!hasLegacyButtonSemantics", "!hasOutput)", @@ -13308,6 +13309,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "hasError,", "hasError:", "hasFinalText", + "hasFlatSubmitState", + "hasFlatSubmitState,", "hasGroups", "hasHeading", "hasInput,", @@ -13859,6 +13862,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "input.getSubmitProps()}>Send", "input.name", "input.trim(),", + "input.trim();", "input.tsx", "input.type", "input/button", @@ -14158,6 +14162,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "isListening?:", "isLoaded", "isLoading", + "isLoading)", "isLoading,", "isLoading:", "isLoading={Boolean(isLoading)}", @@ -16469,7 +16474,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onRemoveAttachment={manageAttachments", "onRemoveAttachment?.(attachment.id);", "onRemoveAttachment?:", - "onRemoveAttachment]);", "onRemoveUpload", "onRemoveUpload(attachment.id)}", "onRemoveUpload(file.id)}", @@ -19621,6 +19625,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "setInput?:", "setInputProp", "setInputProp,", + "setInput]);", "setInstructionsOpen(phase", "setInstructionsOpen]", "setInternal((current)", From b702e884f49a5729c8dc867da8e3c3600e97857e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 21:32:14 +0200 Subject: [PATCH 4/9] fix(chat): honor nested composer overrides --- docs/api-reference/veryfront/chat.md | 6 +- .../chat-composer.shared-context.test.tsx | 86 ++++++++++++++++++- .../chat/chat/composition/chat-root.tsx | 2 + .../chat/composition/use-composer-value.ts | 34 ++++++-- .../chat/chat/contexts/chat-context.tsx | 4 +- .../dev/framework-candidates.generated.ts | 17 ++-- 6 files changed, 130 insertions(+), 19 deletions(-) diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index a5a5b16848..568a6ea89f 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -149,7 +149,7 @@ Result returned from use agent. | `Chat` | Render chat components through the preset or its composable sub-parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/chat-preset.tsx#L37) | | `ChatActions` | ChatActions - render `` for the default preset menu, or compose `ChatActions.Trigger` / `Content` / `Item` (each reads `useChatActions()`) for a custom menu. Mirrors the `ToolCall` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-actions.tsx#L197) | | `ChatAgentPicker` | Render the connected agent switcher, or nothing when there's nothing to switch. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-agent-picker.tsx#L63) | -| `ChatContextProvider` | Render chat context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L106) | +| `ChatContextProvider` | Render chat context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L108) | | `ChatEmpty` | Render the chat empty state. It renders whatever it is given and never hides itself: in a custom layout, gate it on the thread being empty with ` ctx.isEmpty}>`. The `` preset does this for you. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty.tsx#L50) | | `ChatEmptyState` | Compound empty state. Use the namespaced parts to compose the view: `Root`, `Avatar`, `Heading`, `Suggestions`, `Suggestion`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty-state.tsx#L169) | | `ChatIf` | Render chat if. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-if.tsx#L19) | @@ -248,7 +248,7 @@ Result returned from use agent. | `useAgents` | React hook that lists the browser-safe agents a project exposes, via `GET /api/agents`. Companion to `useAgentMetadata` (single agent) - use it to drive an agent switcher, e.g. only rendering a picker when `agents.length > 1`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-agents.ts#L53) | | `useAttachments` | `useAttachments` - the headless state hook for chat attachments: a persistent, cross-conversation registry of uploaded files with the upload / remove / list actions. This is the domain primitive; render any UI on top of it (the `AttachmentsPanel` / `AttachmentPill` components are one skin - bring your own). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-uploads-registry.ts#L353) | | `useChat` | The core chat session hook: manages messages, streaming status, input, submit, regenerate, and branch navigation for a conversation. Powers `` (L1) and is the L3 headless entry point for building a fully custom chat UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/use-chat.ts#L132) | -| `useChatContextOptional` | React hook for chat context optional. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L101) | +| `useChatContextOptional` | React hook for chat context optional. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L103) | | `useChatErrorHandler` | Handler for use chat error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/error-boundary.tsx#L92) | | `useChatInput` | L3 headless composer hook. Must be used within a `` / ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-chat-input.ts#L155) | | `useChatScroll` | `useChatScroll` is the canonical chat scroll hook (RFC 2980). A superset of `useStickToBottom`: same `scrollRef`/`contentRef`/`isAtBottom`/ `scrollToBottom`, plus `viewportRef`, `scrollToStart`/`scrollToEnd`, `scrollToMessage(id)`, and `getViewportProps()` for headless composition. Backward-compatible. Prefer this name in new code. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-stick-to-bottom.ts#L177) | @@ -509,7 +509,7 @@ Result returned from use agent. | `useAttachmentPill` | Read the derived per-pill state provided by `AttachmentPill.Root`. Use it to build a custom pill part; throws if called outside an `AttachmentPill`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachment-pill.tsx#L203) | | `useAttachmentsPanel` | Read the panel state provided by `AttachmentsPanel.Root` (uploads + handlers). Use it to build a custom panel part; throws outside an `AttachmentsPanel`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachments-panel.tsx#L85) | | `useChatActions` | Read the current `ChatActions` preset configuration from a composed `ChatActions.*` part. Throws outside `ChatActions` / `ChatActions.Root`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-actions.tsx#L59) | -| `useChatContext` | Read the enclosing chat's shared state (messages, input, submit/stop, model, attachments, branches, theme). Provided by `` / ``; throws when used outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L98) | +| `useChatContext` | Read the enclosing chat's shared state (messages, input, submit/stop, model, attachments, branches, theme). Provided by `` / ``; throws when used outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L100) | | `useChatInputContext` | Read the enclosing `` context; throws outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L83) | | `useChatInputContextOptional` | Read the enclosing `` context, or null outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L85) | | `useChatSidebarItem` | Read the enclosing ``'s row state (the conversation summary, active flag, rename availability + `startRename`, `remove`, and the `…` menu open state) from a custom item sub-part. Throws outside a ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L277) | diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index 01ed62c7cb..ee08cc09be 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -8,7 +8,7 @@ import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { renderToString } from "react-dom/server"; -import type { FormEvent } from "react"; +import type { ChangeEvent, FormEvent } from "react"; import { JSDOM } from "npm:jsdom@28.0.0"; import { unmountReactRoot } from "#veryfront/react/react-root.test-helpers.ts"; import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; @@ -17,6 +17,7 @@ import type { UseChatResult } from "#veryfront/agent/react"; import { Chat } from "../chat-preset.tsx"; import { ChatInput } from "./chat-composer.tsx"; import { useChatContext } from "../contexts/chat-context.tsx"; +import { useChatInputContext } from "../contexts/composer-context.tsx"; import type { ChatContextValue } from "../contexts/chat-context.tsx"; function makeChat(overrides: Partial = {}): UseChatResult { @@ -259,6 +260,89 @@ describe("react/components/chat/chat/composition/chat-composer shared context", } }); + it("submits explicit nested composer state instead of the enclosing session state", async () => { + let composer: ReturnType | undefined; + let submitted: Parameters[0] | undefined; + let clearedInput: string | undefined; + const removedAttachments: string[] = []; + function ComposerProbe(): null { + composer = useChatInputContext(); + return null; + } + + renderToString( + { + submitted = message; + return Promise.resolve(); + }, + })} + attachments={[]} + > + clearedInput = value} + attachments={[{ + id: "nested-attachment", + name: "nested.pdf", + state: "uploaded", + type: "application/pdf", + url: "https://example.com/nested.pdf", + }]} + onRemoveAttachment={(id) => removedAttachments.push(id)} + > + + + , + ); + + assert(composer, "Expected the nested composer context to be available"); + composer.onSubmit({ preventDefault() {} } as FormEvent); + + assertEquals(submitted, { + text: "nested draft", + files: [{ + type: "file", + mediaType: "application/pdf", + url: "https://example.com/nested.pdf", + filename: "nested.pdf", + }], + }); + assertEquals(clearedInput, ""); + assertEquals(removedAttachments, ["nested-attachment"]); + }); + + it("routes inherited input changes through an explicit composer setter", () => { + let composer: ReturnType | undefined; + let explicitInput: string | undefined; + let sessionInput: string | undefined; + function ComposerProbe(): null { + composer = useChatInputContext(); + return null; + } + + renderToString( + sessionInput = value })} + > + explicitInput = value} + > + + + , + ); + + assert(composer, "Expected the nested composer context to be available"); + composer.onChange?.({ target: { value: "edited draft" } } as ChangeEvent); + + assertEquals(explicitInput, "edited draft"); + assertEquals(sessionInput, undefined); + }); + it("explicit ChatInput.Root props win over the surrounding context values", async () => { const html = renderToString( diff --git a/src/react/components/chat/chat/composition/chat-root.tsx b/src/react/components/chat/chat/composition/chat-root.tsx index 2a8a749f8a..2f438c77e7 100644 --- a/src/react/components/chat/chat/composition/chat-root.tsx +++ b/src/react/components/chat/chat/composition/chat-root.tsx @@ -200,6 +200,7 @@ export function ChatRoot( input, setInput: setInput ?? (() => {}), onSubmit: onSubmit ?? (() => {}), + sendMessage: chat?.sendMessage, onStop, onReload, model, @@ -228,6 +229,7 @@ export function ChatRoot( input, setInput, onSubmit, + chat?.sendMessage, onStop, onReload, model, diff --git a/src/react/components/chat/chat/composition/use-composer-value.ts b/src/react/components/chat/chat/composition/use-composer-value.ts index 3e5e8998f7..9a745b83cf 100644 --- a/src/react/components/chat/chat/composition/use-composer-value.ts +++ b/src/react/components/chat/chat/composition/use-composer-value.ts @@ -78,18 +78,23 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal // propless `` wires itself. Explicit props always win, and a // standalone composer (no ChatContext) keeps the props-only behavior. const chat = useChatContextOptional(); - const chatSetInput = chat?.setInput; + const resolvedSetInput = props.setInput ?? chat?.setInput; const fallbackOnChange = React.useCallback( (e: React.ChangeEvent) => - chatSetInput?.(e.target.value), - [chatSetInput], + resolvedSetInput?.(e.target.value), + [resolvedSetInput], ); + const hasExplicitSubmitState = props.input !== undefined || + props.setInput !== undefined || props.attachments !== undefined || + props.onRemoveAttachment !== undefined; const p = { ...props, input: props.input ?? chat?.input ?? "", onChange: props.onChange ?? fallbackOnChange, - setInput: props.setInput ?? chat?.setInput, + setInput: resolvedSetInput, onSubmit: props.onSubmit ?? chat?.onSubmit, + sendMessage: props.sendMessage ?? + (props.onSubmit === undefined && hasExplicitSubmitState ? chat?.sendMessage : undefined), isLoading: props.isLoading ?? chat?.isLoading, stop: props.stop ?? chat?.onStop, model: props.model ?? chat?.model, @@ -112,7 +117,7 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal // When `sendMessage` is supplied the composer owns submit: trim, wait for // in-flight uploads, fold resolved attachments into file parts, send, clear. // Otherwise fall back to the caller's explicit `onSubmit` (controlled mode). - const { sendMessage, setInput, onClearAttachments, onSubmit } = p; + const { sendMessage, setInput, onClearAttachments, onSubmit, onRemoveAttachment } = p; const onSubmitEffective = React.useCallback((e?: React.FormEvent) => { if (!sendMessage) { onSubmit?.(e); @@ -126,8 +131,23 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal if (!text && files.length === 0) return; sendMessage({ text, ...(files.length > 0 ? { files } : {}) }); setInput?.(""); - onClearAttachments?.(); - }, [canSubmit, sendMessage, onSubmit, setInput, onClearAttachments, p.input, p.attachments]); + if (onClearAttachments) { + onClearAttachments(); + } else { + for (const attachment of attachments) { + if (attachment.url) onRemoveAttachment?.(attachment.id); + } + } + }, [ + canSubmit, + sendMessage, + onSubmit, + setInput, + onClearAttachments, + onRemoveAttachment, + p.input, + p.attachments, + ]); return React.useMemo(() => ({ input: p.input, diff --git a/src/react/components/chat/chat/contexts/chat-context.tsx b/src/react/components/chat/chat/contexts/chat-context.tsx index 2860e14549..2119024c57 100644 --- a/src/react/components/chat/chat/contexts/chat-context.tsx +++ b/src/react/components/chat/chat/contexts/chat-context.tsx @@ -8,7 +8,7 @@ import * as React from "react"; import { createStrictContext } from "../../../create-strict-context.ts"; -import type { ChatMessage, ChatStatus } from "#veryfront/agent/react"; +import type { ChatFilePart, ChatMessage, ChatStatus } from "#veryfront/agent/react"; import type { ChatTheme } from "../../theme.ts"; import type { ModelOption } from "../../model-selector.tsx"; import type { AttachmentInfo } from "../components/attachment-pill.tsx"; @@ -37,6 +37,8 @@ export interface ChatContextValue { // Submit / Stop onSubmit: (e?: React.FormEvent) => void | Promise; + /** Send resolved composer text and attachments through the shared session. */ + sendMessage?: (message: { text: string; files?: ChatFilePart[] }) => void | Promise; onStop?: () => void; onReload?: () => void; diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index 2d004000fc..a9e23956b9 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -7417,7 +7417,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[beginPending,", "[canAttach,", "[canOpen,", - "[canSubmit,", "[canUseVoice,", "[cancelClose,", "[cancelClose]);", @@ -7431,7 +7430,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[cause];", "[chat,", "[chat.messages,", - "[chatSetInput],", "[children,", "[children]);", "[clearPending]);", @@ -7656,6 +7654,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[requestedActiveId,", "[reset]);", "[resolvedMode,", + "[resolvedSetInput],", "[rightDesktop,", "[rightMobile,", "[role=", @@ -9538,7 +9537,8 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "chat?.onStop,", "chat?.onSubmit,", "chat?.reload;", - "chat?.setInput,", + "chat?.sendMessage", + "chat?.sendMessage,", "chat?.setInput;", "chat?.setModel;", "chat?.status", @@ -9555,8 +9555,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "chatButtonVariants,", "chatContainerVariants", "chatContainerVariants,", - "chatSetInput", - "chatSetInput?.(e.target.value),", "chatStatus", "chatStatus:", "chatTokens", @@ -13308,6 +13306,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "hasError", "hasError,", "hasError:", + "hasExplicitSubmitState", "hasFinalText", "hasFlatSubmitState", "hasFlatSubmitState,", @@ -16159,8 +16158,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onCitationClick:", "onCitationClick?.(index);", "onCitationClick?:", + "onClearAttachments();", + "onClearAttachments)", "onClearAttachments,", - "onClearAttachments?.();", "onClearAttachments?:", "onClick", "onClick(e,", @@ -16998,7 +16998,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "p.attachments", "p.attachments,", "p.attachments?.some((attachment)", - "p.attachments]);", "p.input,", "p.input.trim().length", "p.input.trim();", @@ -17774,6 +17773,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "props.onSubmit", "props.ref", "props.renderItem;", + "props.sendMessage", "props.setInput", "props.skeleton", "props.stop", @@ -18792,6 +18792,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "resolvedMode:", "resolvedMode;", "resolvedMode={resolvedMode}", + "resolvedSetInput", + "resolvedSetInput,", + "resolvedSetInput?.(e.target.value),", "resolvedStreaming", "resolvedStreaming,", "resolved[key]", From 93fe37d5b3c6ceb0dc9995ef4403f192b63b54c8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 21:43:39 +0200 Subject: [PATCH 5/9] fix(chat): preserve parent submit overrides --- .../chat-composer.shared-context.test.tsx | 32 +++++++++++++++++++ .../chat/chat/composition/chat-root.tsx | 3 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index ee08cc09be..df847d8a4f 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -343,6 +343,38 @@ describe("react/components/chat/chat/composition/chat-composer shared context", assertEquals(sessionInput, undefined); }); + it("preserves an enclosing explicit submit override for nested composer state", () => { + let composer: ReturnType | undefined; + let parentSubmits = 0; + let sessionSends = 0; + function ComposerProbe(): null { + composer = useChatInputContext(); + return null; + } + + renderToString( + { + sessionSends += 1; + return Promise.resolve(); + }, + })} + onSubmit={() => parentSubmits += 1} + > + {}}> + + + , + ); + + assert(composer, "Expected the nested composer context to be available"); + composer.onSubmit({ preventDefault() {} } as FormEvent); + + assertEquals(parentSubmits, 1); + assertEquals(sessionSends, 0); + }); + it("explicit ChatInput.Root props win over the surrounding context values", async () => { const html = renderToString( diff --git a/src/react/components/chat/chat/composition/chat-root.tsx b/src/react/components/chat/chat/composition/chat-root.tsx index 2f438c77e7..b620919d36 100644 --- a/src/react/components/chat/chat/composition/chat-root.tsx +++ b/src/react/components/chat/chat/composition/chat-root.tsx @@ -200,7 +200,7 @@ export function ChatRoot( input, setInput: setInput ?? (() => {}), onSubmit: onSubmit ?? (() => {}), - sendMessage: chat?.sendMessage, + sendMessage: onSubmitProp === undefined ? chat?.sendMessage : undefined, onStop, onReload, model, @@ -230,6 +230,7 @@ export function ChatRoot( setInput, onSubmit, chat?.sendMessage, + onSubmitProp, onStop, onReload, model, From 6773c72447ab6eb3aa501cadcfd65a7eb31ab56c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 21:53:08 +0200 Subject: [PATCH 6/9] fix(chat): honor explicit attachment cleanup --- .../chat-composer.shared-context.test.tsx | 46 +++++++++++++++++++ .../chat/composition/use-composer-value.ts | 2 +- .../dev/framework-candidates.generated.ts | 1 + 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index df847d8a4f..05987a7252 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -205,6 +205,52 @@ describe("react/components/chat/chat/composition/chat-composer shared context", } }); + it("honors an explicit attachment cleanup with inherited submit state", () => { + let composer: ReturnType | undefined; + let submitted: Parameters[0] | undefined; + let clearCalls = 0; + function ComposerProbe(): null { + composer = useChatInputContext(); + return null; + } + + renderToString( + { + submitted = message; + return Promise.resolve(); + }, + })} + attachments={[{ + id: "att-1", + name: "notes.pdf", + state: "uploaded", + type: "application/pdf", + url: "https://example.com/notes.pdf", + }]} + > + clearCalls += 1}> + + + , + ); + + assert(composer, "Expected the nested composer context to be available"); + composer.onSubmit({ preventDefault() {} } as FormEvent); + + assertEquals(submitted, { + text: "", + files: [{ + type: "file", + mediaType: "application/pdf", + url: "https://example.com/notes.pdf", + filename: "notes.pdf", + }], + }); + assertEquals(clearCalls, 1); + }); + it("submits the resolved flat input instead of the session draft", async () => { const dom = new JSDOM( '
', diff --git a/src/react/components/chat/chat/composition/use-composer-value.ts b/src/react/components/chat/chat/composition/use-composer-value.ts index 9a745b83cf..16ea0fe6df 100644 --- a/src/react/components/chat/chat/composition/use-composer-value.ts +++ b/src/react/components/chat/chat/composition/use-composer-value.ts @@ -86,7 +86,7 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal ); const hasExplicitSubmitState = props.input !== undefined || props.setInput !== undefined || props.attachments !== undefined || - props.onRemoveAttachment !== undefined; + props.onRemoveAttachment !== undefined || props.onClearAttachments !== undefined; const p = { ...props, input: props.input ?? chat?.input ?? "", diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index a9e23956b9..aa19da9bf3 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -17764,6 +17764,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "props.models", "props.onAttach", "props.onChange", + "props.onClearAttachments", "props.onDelete", "props.onModelChange", "props.onNew", From b70fd45fa5bdbce363f7c64bcd86590367e619f5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 22:11:39 +0200 Subject: [PATCH 7/9] fix(chat): restore focus for inherited attachments --- docs/api-reference/veryfront/chat.md | 2 +- .../chat-composer.shared-context.test.tsx | 53 +++++++++++++++++++ .../chat/chat/composition/chat-composer.tsx | 8 +-- .../dev/framework-candidates.generated.ts | 9 ++-- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 568a6ea89f..8df2236cd2 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -153,7 +153,7 @@ Result returned from use agent. | `ChatEmpty` | Render the chat empty state. It renders whatever it is given and never hides itself: in a custom layout, gate it on the thread being empty with ` ctx.isEmpty}>`. The `` preset does this for you. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty.tsx#L50) | | `ChatEmptyState` | Compound empty state. Use the namespaced parts to compose the view: `Root`, `Avatar`, `Heading`, `Suggestions`, `Suggestion`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty-state.tsx#L169) | | `ChatIf` | Render chat if. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-if.tsx#L19) | -| `ChatInput` | ChatInput - render `` for the default composer, or compose `ChatInput.Field` + `ChatInput.Send`/`Stop`/`Voice`/`Model`/`Attach`/`Export`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L335) | +| `ChatInput` | ChatInput - render `` for the default composer, or compose `ChatInput.Field` + `ChatInput.Send`/`Stop`/`Voice`/`Model`/`Attach`/`Export`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L337) | | `ChatInputAttach` | Attachment `+` control. When attaching files is the only action, `+` opens the file dialog directly. When `onSelectAttachment` is also set it becomes a portalled `+` menu (Studio `PromptForm`'s `PlusMenu`) with "Add photos & files" and "Select document". | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-input-attach.tsx#L30) | | `ChatInputContextProvider` | Provider for `` context (RFC 2980 name). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L87) | | `ChatInputExport` | Download the supplied conversation as Markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-composer.tsx#L92) | diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index 05987a7252..fc140577d0 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -603,6 +603,59 @@ describe("react/components/chat/chat/composition/chat-composer shared context", ); }); + it("restores field focus after selecting an attachment inherited from Chat.Root", async () => { + const dom = new JSDOM( + '
', + { url: "https://example.com/" }, + ); + const restore = installDomGlobals(dom); + let attachCalls = 0; + let root: Root | undefined; + + try { + const rootElement = document.getElementById("root"); + assert(rootElement, "Expected root element to exist"); + const createdRoot = createRoot(rootElement); + root = createdRoot; + flushSync(() => { + createdRoot.render( + attachCalls += 1}> + + , + ); + }); + + const field = document.querySelector("textarea"); + const upload = document.querySelector('input[aria-label="Upload file"]'); + assert(field, "Expected the composer field to render"); + assert(upload, "Expected the inherited attachment picker to render"); + + Object.defineProperties(field, { + attachEvent: { value: () => {} }, + detachEvent: { value: () => {} }, + }); + upload.focus(); + Object.defineProperty(upload, "files", { + configurable: true, + value: { + 0: new dom.window.File(["notes"], "notes.txt", { type: "text/plain" }), + length: 1, + item: (index: number) => index === 0 ? upload.files?.[0] ?? null : null, + }, + }); + flushSync(() => upload.dispatchEvent(new dom.window.Event("change", { bubbles: true }))); + + assertEquals(attachCalls, 1, "the inherited attachment handler must run once"); + assert( + document.activeElement === field, + "attachment selection must restore field focus", + ); + } finally { + if (root) await unmountReactRoot(root); + restore(); + } + }); + it("an explicit null flat prop overrides the session value instead of inheriting it", () => { let observed: ChatContextValue | undefined; function ContextProbe(): null { diff --git a/src/react/components/chat/chat/composition/chat-composer.tsx b/src/react/components/chat/chat/composition/chat-composer.tsx index 155b4418d0..ae6b21f80c 100644 --- a/src/react/components/chat/chat/composition/chat-composer.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.tsx @@ -241,17 +241,19 @@ function ChatInputBase( }); // Attach + drop fall back to the context-resolved handler (like // `ChatInput.Root`), so a propless composer inside `` - // keeps a working picker and drop zone. Explicit props stay focus-wrapped. + // keeps a working picker and drop zone. Both explicit and inherited + // handlers restore field focus after a selection. + const resolvedAttach = handleAttach ?? withFocus(baseCtxValue.onAttach); const { isDragActive, onDragEnter, onDragLeave, onDragOver, onDrop: onFileDrop, - } = useDropZone(withFocus(onDrop ?? onAttach) ?? baseCtxValue.onAttach); + } = useDropZone(withFocus(onDrop) ?? resolvedAttach); const { contextValue, fileInput } = useChatInputAttachmentPicker( baseCtxValue, - baseCtxValue.onAttach, + resolvedAttach, attachAccept, ); diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index aa19da9bf3..ed885217a9 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -8745,7 +8745,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "baseContextValue:", "baseCtxValue", "baseCtxValue,", - "baseCtxValue.onAttach);", "baseCtxValue.onAttach,", "baseId", "baseId,", @@ -12689,7 +12688,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "focus-visible:ring-offset-2", "focus-visible:ring-offset-[var(--background)]", "focus-within:opacity-100", - "focus-wrapped.", "focus.", "focus/dismiss", "focus:bg-[var(--tertiary)]", @@ -16082,7 +16080,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "onAttach", "onAttach(e.target.files);", "onAttach(event.target.files);", - "onAttach)", "onAttach,", "onAttach:", "onAttach={effectiveOnAttach}", @@ -18776,6 +18773,9 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "resolvedAnchor", "resolvedAnchor,", "resolvedAnchor;", + "resolvedAttach", + "resolvedAttach);", + "resolvedAttach,", "resolvedContentId", "resolvedContentId]);", "resolvedDescribedBy", @@ -22156,7 +22156,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "useDrawer:", "useDropZone", "useDropZone(", - "useDropZone(withFocus(onDrop", + "useDropZone(withFocus(onDrop)", "useDropZone}.", "useEffectHook", "useEffectHook(effect,", @@ -22805,6 +22805,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "with.", "with?", "withFocus", + "withFocus(baseCtxValue.onAttach);", "withFocus(onAttach);", "within", "without", From 1afb10d53b15f8f7313c31182fa563bfcd65c0c5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 22:29:26 +0200 Subject: [PATCH 8/9] fix(chat): honor explicit loading overrides --- .../chat-composer.shared-context.test.tsx | 43 ++++++++++++++++++- .../chat/composition/use-composer-value.ts | 3 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index fc140577d0..88c45b7f81 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -406,7 +406,9 @@ describe("react/components/chat/chat/composition/chat-composer shared context", return Promise.resolve(); }, })} - onSubmit={() => parentSubmits += 1} + onSubmit={() => { + parentSubmits += 1; + }} > {}}> @@ -479,6 +481,45 @@ describe("react/components/chat/chat/composition/chat-composer shared context", } }); + it("uses the session sender when isLoading false explicitly re-enables submission", () => { + let composer: ReturnType | undefined; + let guardedSubmits = 0; + let sessionSends = 0; + function ComposerProbe(): null { + composer = useChatInputContext(); + return null; + } + + renderToString( + { + guardedSubmits += 1; + return Promise.resolve(); + }, + sendMessage: () => { + sessionSends += 1; + return Promise.resolve(); + }, + })} + > + + + + , + ); + + assert(composer, "Expected the nested composer context to be available"); + assertEquals(composer.isLoading, false); + assertEquals(composer.canSubmit, true); + composer.onSubmit({ preventDefault() {} } as FormEvent); + + assertEquals(sessionSends, 1, "the explicitly enabled composer must use the session sender"); + assertEquals(guardedSubmits, 0, "the inherited loading-guarded submitter must not run"); + }); + it("a standalone ChatInput.Root with full explicit props behaves exactly as before", async () => { const html = renderToString( Date: Sun, 16 Aug 2026 22:43:54 +0200 Subject: [PATCH 9/9] fix(chat): submit with resolved model --- docs/api-reference/veryfront/chat.md | 54 +++++++++---------- src/agent/react/use-chat/types.ts | 3 +- .../react/use-chat/use-chat.status.test.tsx | 33 ++++++++++++ src/agent/react/use-chat/use-chat.ts | 6 ++- .../chat-composer.shared-context.test.tsx | 39 ++++++++++++++ .../chat/chat/composition/chat-root.tsx | 21 ++++++-- .../chat/composition/use-composer-value.ts | 11 ++-- .../chat/chat/contexts/chat-context.tsx | 6 ++- .../dev/framework-candidates.generated.ts | 4 +- 9 files changed, 136 insertions(+), 41 deletions(-) diff --git a/docs/api-reference/veryfront/chat.md b/docs/api-reference/veryfront/chat.md index 8df2236cd2..c8920027c6 100644 --- a/docs/api-reference/veryfront/chat.md +++ b/docs/api-reference/veryfront/chat.md @@ -82,30 +82,30 @@ Options accepted by use chat. `useChat` result -| Property | Type | Description | Source | -| -------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `messages` | `ChatMessage[]` | All messages in the conversation | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L95) | -| `input` | `string` | Current input value | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L96) | -| `isLoading` | `boolean` | Whether a request is in flight | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L98) | -| `status` | `ChatStatus` | Streaming lifecycle of the current turn (AI-SDK parity). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L100) | -| `streamingMessageId` | `string \| null` | Id of the assistant message currently streaming, or `null` when idle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L102) | -| `error` | `Error \| null` | Last error (if any) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L103) | -| `model` | `string \| undefined` | Current model override (undefined = use agent default) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L105) | -| `activeModel` | `string \| undefined` | The actual model being used after auto-upgrade (e.g. "Anthropic/claude-sonnet-4-20250514") | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L107) | -| `inferenceMode` | `InferenceMode` | Where inference is currently happening | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L109) | -| `setInput` | (input: string) => void | Set input value | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L110) | -| `setModel` | (model: string | undefined) => void | Change the model for subsequent requests | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L112) | -| `sendMessage` | (message: { text: string; files?: ChatFilePart[] }) => Promise<void> | Send a message programmatically | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L113) | -| `editMessage` | (messageId: string, newText: string) => Promise<void> | Edit a user message and resubmit - truncates history to that point | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L115) | -| `getBranches` | (messageId: string) => BranchInfo | Get branch info for a message (returns { current, total }; total=1 if no branches) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L117) | -| `switchBranch` | (messageId: string, branchIndex: number) => void | Switch to a different branch at a given message | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L119) | -| `reload` | () => Promise<void> | Re-send last user message | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L120) | -| `stop` | () => void | Abort current request | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L121) | -| `setMessages` | (messages: ChatMessage[]) => void | Replace message history | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L122) | -| `addToolOutput` | (output: ToolOutput) => void | Submit client-side tool result | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L123) | -| `data?` | `unknown` | Extra data from server response | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L124) | -| `handleInputChange` | (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void | Bind to input onChange | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L125) | -| `handleSubmit` | (e?: React.FormEvent) => Promise<void> | Submit current input | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L126) | +| Property | Type | Description | Source | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `messages` | `ChatMessage[]` | All messages in the conversation | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L95) | +| `input` | `string` | Current input value | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L96) | +| `isLoading` | `boolean` | Whether a request is in flight | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L98) | +| `status` | `ChatStatus` | Streaming lifecycle of the current turn (AI-SDK parity). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L100) | +| `streamingMessageId` | `string \| null` | Id of the assistant message currently streaming, or `null` when idle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L102) | +| `error` | `Error \| null` | Last error (if any) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L103) | +| `model` | `string \| undefined` | Current model override (undefined = use agent default) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L105) | +| `activeModel` | `string \| undefined` | The actual model being used after auto-upgrade (e.g. "Anthropic/claude-sonnet-4-20250514") | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L107) | +| `inferenceMode` | `InferenceMode` | Where inference is currently happening | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L109) | +| `setInput` | (input: string) => void | Set input value | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L110) | +| `setModel` | (model: string | undefined) => void | Change the model for subsequent requests | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L112) | +| `sendMessage` | (message: { text: string; files?: ChatFilePart[]; model?: string }) => Promise<void> | Send a message. A message model overrides the session model for this request only. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L114) | +| `editMessage` | (messageId: string, newText: string) => Promise<void> | Edit a user message and resubmit - truncates history to that point | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L116) | +| `getBranches` | (messageId: string) => BranchInfo | Get branch info for a message (returns { current, total }; total=1 if no branches) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L118) | +| `switchBranch` | (messageId: string, branchIndex: number) => void | Switch to a different branch at a given message | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L120) | +| `reload` | () => Promise<void> | Re-send last user message | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L121) | +| `stop` | () => void | Abort current request | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L122) | +| `setMessages` | (messages: ChatMessage[]) => void | Replace message history | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L123) | +| `addToolOutput` | (output: ToolOutput) => void | Submit client-side tool result | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L124) | +| `data?` | `unknown` | Extra data from server response | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L125) | +| `handleInputChange` | (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void | Bind to input onChange | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L126) | +| `handleSubmit` | (e?: React.FormEvent) => Promise<void> | Submit current input | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/types.ts#L127) | ### `UseAgentOptions` @@ -149,7 +149,7 @@ Result returned from use agent. | `Chat` | Render chat components through the preset or its composable sub-parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/chat-preset.tsx#L37) | | `ChatActions` | ChatActions - render `` for the default preset menu, or compose `ChatActions.Trigger` / `Content` / `Item` (each reads `useChatActions()`) for a custom menu. Mirrors the `ToolCall` compound: render it, or compose it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-actions.tsx#L197) | | `ChatAgentPicker` | Render the connected agent switcher, or nothing when there's nothing to switch. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-agent-picker.tsx#L63) | -| `ChatContextProvider` | Render chat context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L108) | +| `ChatContextProvider` | Render chat context provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L110) | | `ChatEmpty` | Render the chat empty state. It renders whatever it is given and never hides itself: in a custom layout, gate it on the thread being empty with ` ctx.isEmpty}>`. The `` preset does this for you. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty.tsx#L50) | | `ChatEmptyState` | Compound empty state. Use the namespaced parts to compose the view: `Root`, `Avatar`, `Heading`, `Suggestions`, `Suggestion`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-empty-state.tsx#L169) | | `ChatIf` | Render chat if. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/composition/chat-if.tsx#L19) | @@ -248,7 +248,7 @@ Result returned from use agent. | `useAgents` | React hook that lists the browser-safe agents a project exposes, via `GET /api/agents`. Companion to `useAgentMetadata` (single agent) - use it to drive an agent switcher, e.g. only rendering a picker when `agents.length > 1`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-agents.ts#L53) | | `useAttachments` | `useAttachments` - the headless state hook for chat attachments: a persistent, cross-conversation registry of uploaded files with the upload / remove / list actions. This is the domain primitive; render any UI on top of it (the `AttachmentsPanel` / `AttachmentPill` components are one skin - bring your own). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-uploads-registry.ts#L353) | | `useChat` | The core chat session hook: manages messages, streaming status, input, submit, regenerate, and branch navigation for a conversation. Powers `` (L1) and is the L3 headless entry point for building a fully custom chat UI. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/react/use-chat/use-chat.ts#L132) | -| `useChatContextOptional` | React hook for chat context optional. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L103) | +| `useChatContextOptional` | React hook for chat context optional. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L105) | | `useChatErrorHandler` | Handler for use chat error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/error-boundary.tsx#L92) | | `useChatInput` | L3 headless composer hook. Must be used within a `` / ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-chat-input.ts#L155) | | `useChatScroll` | `useChatScroll` is the canonical chat scroll hook (RFC 2980). A superset of `useStickToBottom`: same `scrollRef`/`contentRef`/`isAtBottom`/ `scrollToBottom`, plus `viewportRef`, `scrollToStart`/`scrollToEnd`, `scrollToMessage(id)`, and `getViewportProps()` for headless composition. Backward-compatible. Prefer this name in new code. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/hooks/use-stick-to-bottom.ts#L177) | @@ -509,7 +509,7 @@ Result returned from use agent. | `useAttachmentPill` | Read the derived per-pill state provided by `AttachmentPill.Root`. Use it to build a custom pill part; throws if called outside an `AttachmentPill`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachment-pill.tsx#L203) | | `useAttachmentsPanel` | Read the panel state provided by `AttachmentsPanel.Root` (uploads + handlers). Use it to build a custom panel part; throws outside an `AttachmentsPanel`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/attachments-panel.tsx#L85) | | `useChatActions` | Read the current `ChatActions` preset configuration from a composed `ChatActions.*` part. Throws outside `ChatActions` / `ChatActions.Root`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat-actions.tsx#L59) | -| `useChatContext` | Read the enclosing chat's shared state (messages, input, submit/stop, model, attachments, branches, theme). Provided by `` / ``; throws when used outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L100) | +| `useChatContext` | Read the enclosing chat's shared state (messages, input, submit/stop, model, attachments, branches, theme). Provided by `` / ``; throws when used outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/chat-context.tsx#L102) | | `useChatInputContext` | Read the enclosing `` context; throws outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L83) | | `useChatInputContextOptional` | Read the enclosing `` context, or null outside one. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/contexts/composer-context.tsx#L85) | | `useChatSidebarItem` | Read the enclosing ``'s row state (the conversation summary, active flag, rename availability + `startRename`, `remove`, and the `…` menu open state) from a custom item sub-part. Throws outside a ``. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/react/components/chat/chat/components/sidebar.tsx#L277) | diff --git a/src/agent/react/use-chat/types.ts b/src/agent/react/use-chat/types.ts index f4eaecda69..c8fd477461 100644 --- a/src/agent/react/use-chat/types.ts +++ b/src/agent/react/use-chat/types.ts @@ -110,7 +110,8 @@ export interface UseChatResult { setInput: (input: string) => void; /** Change the model for subsequent requests */ setModel: (model: string | undefined) => void; - sendMessage: (message: { text: string; files?: ChatFilePart[] }) => Promise; + /** Send a message. A message model overrides the session model for this request only. */ + sendMessage: (message: { text: string; files?: ChatFilePart[]; model?: string }) => Promise; /** Edit a user message and resubmit — truncates history to that point */ editMessage: (messageId: string, newText: string) => Promise; /** Get branch info for a message (returns { current, total }; total=1 if no branches) */ diff --git a/src/agent/react/use-chat/use-chat.status.test.tsx b/src/agent/react/use-chat/use-chat.status.test.tsx index 6994445b65..a0615697dd 100644 --- a/src/agent/react/use-chat/use-chat.status.test.tsx +++ b/src/agent/react/use-chat/use-chat.status.test.tsx @@ -192,6 +192,39 @@ describe("react/agent/useChat status lifecycle", () => { } }); + it("uses a per-message model override for the request and response metadata", async () => { + const restoreDom = installDom(); + const originalFetch = globalThis.fetch; + let requestBody: { model?: string } | undefined; + globalThis.fetch = (_input, init) => { + requestBody = JSON.parse(String((init as { body?: unknown } | undefined)?.body)); + return Promise.resolve(sseResponse()); + }; + let latest: UseChatResult | null = null; + + function Capture(): null { + latest = useChat({ api: "/api/ag-ui", model: "session-model" }); + return null; + } + + const root = createRoot(document.getElementById("root")!); + try { + flushSync(() => root.render()); + await latest!.sendMessage({ text: "Hello", model: "flat-model" }); + await settle(); + + assertEquals(requestBody?.model, "flat-model"); + const assistant = latest!.messages.find((message) => message.role === "assistant"); + assert(assistant, "the streamed assistant response should be retained"); + assertEquals(assistant.metadata?.model, "flat-model"); + } finally { + flushSync(() => root.unmount()); + await settle(); + globalThis.fetch = originalFetch; + restoreDom(); + } + }); + it("keeps a successful client tool output persistable", async () => { const restoreDom = installDom(); let latest: UseChatResult | null = null; diff --git a/src/agent/react/use-chat/use-chat.ts b/src/agent/react/use-chat/use-chat.ts index b6d9013b12..5c512944cb 100644 --- a/src/agent/react/use-chat/use-chat.ts +++ b/src/agent/react/use-chat/use-chat.ts @@ -215,6 +215,7 @@ function useChatState(options: UseChatOptions): ResettableUseChatResult { message: { text: string; files?: ChatFilePart[]; + model?: string; baseMessages?: ChatMessage[]; userMessageId?: string; }, @@ -241,6 +242,7 @@ function useChatState(options: UseChatOptions): ResettableUseChatResult { let didError = false; try { const allMessages = [...base, userMessage]; + const requestModel = message.model ?? model; const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -257,7 +259,7 @@ function useChatState(options: UseChatOptions): ResettableUseChatResult { credentials: options.credentials, body: JSON.stringify({ messages: allMessages, - ...(model ? { model } : {}), + ...(requestModel ? { model: requestModel } : {}), ...options.body, }), signal: abortController.signal, @@ -301,7 +303,7 @@ function useChatState(options: UseChatOptions): ResettableUseChatResult { setStreamingMessageId(currentMessageIdRef.current); }; // Mutable local — updated by onData before onMessage/onUpdate use it. - let serverModel: string | undefined = model; + let serverModel: string | undefined = requestModel; setActiveModel((current) => isLatestRequest(requestIdRef.current, requestId) ? undefined : current ); diff --git a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx index 88c45b7f81..94171a1c4a 100644 --- a/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx +++ b/src/react/components/chat/chat/composition/chat-composer.shared-context.test.tsx @@ -306,6 +306,45 @@ describe("react/components/chat/chat/composition/chat-composer shared context", } }); + it("submits with the resolved flat model instead of the session model", () => { + let composer: ReturnType | undefined; + let submitted: { text: string; model?: string } | undefined; + let textOnlySubmits = 0; + function ComposerProbe(): null { + composer = useChatInputContext(); + return null; + } + + renderToString( + { + submitted = message; + return Promise.resolve(); + }, + handleSubmit: () => { + textOnlySubmits += 1; + return Promise.resolve(); + }, + })} + model="flat-model" + onModelChange={() => {}} + > + + + + , + ); + + assert(composer, "Expected the nested composer context to be available"); + composer.onSubmit({ preventDefault() {} } as FormEvent); + + assertEquals(textOnlySubmits, 0, "flat model submission must bypass the session submitter"); + assertEquals(submitted, { text: "model-specific draft", model: "flat-model" }); + }); + it("submits explicit nested composer state instead of the enclosing session state", async () => { let composer: ReturnType | undefined; let submitted: Parameters[0] | undefined; diff --git a/src/react/components/chat/chat/composition/chat-root.tsx b/src/react/components/chat/chat/composition/chat-root.tsx index b620919d36..ac43bbb734 100644 --- a/src/react/components/chat/chat/composition/chat-root.tsx +++ b/src/react/components/chat/chat/composition/chat-root.tsx @@ -147,8 +147,9 @@ export function ChatRoot( const error = errorProp !== undefined ? errorProp : (chat?.error ?? null); const input = inputProp ?? chat?.input ?? ""; const setInput = setInputProp ?? chat?.setInput; + const model = modelProp ?? chat?.model; const hasFlatSubmitState = inputProp !== undefined || setInputProp !== undefined || - isLoadingProp !== undefined; + isLoadingProp !== undefined || modelProp !== undefined; const submitSession = React.useCallback((e?: React.FormEvent) => { if (!chat) return; if (hasPendingAttachments(attachments)) { @@ -167,12 +168,24 @@ export function ChatRoot( for (const attachment of attachments) { if (attachment.url) onRemoveAttachment?.(attachment.id); } - return chat.sendMessage({ text, ...(files.length > 0 ? { files } : {}) }); - }, [attachments, chat, hasFlatSubmitState, input, isLoading, onRemoveAttachment, setInput]); + return chat.sendMessage({ + text, + ...(files.length > 0 ? { files } : {}), + ...(model !== undefined ? { model } : {}), + }); + }, [ + attachments, + chat, + hasFlatSubmitState, + input, + isLoading, + model, + onRemoveAttachment, + setInput, + ]); const onSubmit = onSubmitProp ?? (chat ? submitSession : undefined); const onStop = onStopProp ?? chat?.stop; const onReload = onReloadProp ?? chat?.reload; - const model = modelProp ?? chat?.model; const onModelChange = onModelChangeProp ?? chat?.setModel; const editMessage = editMessageProp ?? chat?.editMessage; const getBranches = getBranchesProp ?? chat?.getBranches; diff --git a/src/react/components/chat/chat/composition/use-composer-value.ts b/src/react/components/chat/chat/composition/use-composer-value.ts index 6449f819b2..8db84820af 100644 --- a/src/react/components/chat/chat/composition/use-composer-value.ts +++ b/src/react/components/chat/chat/composition/use-composer-value.ts @@ -54,7 +54,7 @@ interface ComposerSubmitProps { * Send directly through composer-owned submission. When supplied, `setInput` * clears the controlled input after this handler runs. */ - sendMessage?: (message: { text: string; files?: ChatFilePart[] }) => void; + sendMessage?: (message: { text: string; files?: ChatFilePart[]; model?: string }) => void; /** Update the controlled input value for headless context consumers. */ setInput?: (value: string) => void; } @@ -87,7 +87,7 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal const hasExplicitSubmitState = props.input !== undefined || props.setInput !== undefined || props.attachments !== undefined || props.onRemoveAttachment !== undefined || props.onClearAttachments !== undefined || - props.isLoading !== undefined; + props.isLoading !== undefined || props.model !== undefined; const p = { ...props, input: props.input ?? chat?.input ?? "", @@ -130,7 +130,11 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal const text = p.input.trim(); const files = attachmentsToFileParts(attachments); if (!text && files.length === 0) return; - sendMessage({ text, ...(files.length > 0 ? { files } : {}) }); + sendMessage({ + text, + ...(files.length > 0 ? { files } : {}), + ...(p.model !== undefined ? { model: p.model } : {}), + }); setInput?.(""); if (onClearAttachments) { onClearAttachments(); @@ -148,6 +152,7 @@ export function useComposerValue(props: ComposerStateProps): ChatInputContextVal onRemoveAttachment, p.input, p.attachments, + p.model, ]); return React.useMemo(() => ({ diff --git a/src/react/components/chat/chat/contexts/chat-context.tsx b/src/react/components/chat/chat/contexts/chat-context.tsx index 2119024c57..d1b01467a1 100644 --- a/src/react/components/chat/chat/contexts/chat-context.tsx +++ b/src/react/components/chat/chat/contexts/chat-context.tsx @@ -37,8 +37,10 @@ export interface ChatContextValue { // Submit / Stop onSubmit: (e?: React.FormEvent) => void | Promise; - /** Send resolved composer text and attachments through the shared session. */ - sendMessage?: (message: { text: string; files?: ChatFilePart[] }) => void | Promise; + /** Send resolved composer text, attachments, and an optional request model through the session. */ + sendMessage?: (message: { text: string; files?: ChatFilePart[]; model?: string }) => + | void + | Promise; onStop?: () => void; onReload?: () => void; diff --git a/src/server/handlers/dev/framework-candidates.generated.ts b/src/server/handlers/dev/framework-candidates.generated.ts index ed885217a9..7bc028d4e3 100644 --- a/src/server/handlers/dev/framework-candidates.generated.ts +++ b/src/server/handlers/dev/framework-candidates.generated.ts @@ -2288,6 +2288,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "ChatFilePart", "ChatFilePart,", "ChatFilePart[]", + "ChatFilePart[];", "ChatGPT).", "ChatIdleView", "ChatIf", @@ -7404,7 +7405,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "[api,", "[aria-hidden=", "[attachChildRef,", - "[attachments,", "[avatarSrc]);", "[b])", "[background-position:100%_center]", @@ -17002,6 +17002,7 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "p.isListening,", "p.isLoading", "p.isLoading,", + "p.model", "p.model,", "p.models", "p.models,", @@ -19629,7 +19630,6 @@ export const FRAMEWORK_CANDIDATES: readonly string[] = [ "setInput?:", "setInputProp", "setInputProp,", - "setInput]);", "setInstructionsOpen(phase", "setInstructionsOpen]", "setInternal((current)",