From a154eb4e242c6b16a39b97fa69b8b7b312197f4b Mon Sep 17 00:00:00 2001 From: "pouriade.com" Date: Fri, 23 Oct 2026 10:31:21 -0700 Subject: [PATCH] Move the composer's model and effort under the card The composer's toolbar now holds only Attach and Send, and the model and effort selects sit in a quiet line under the card, ComposerSettings in composer-parts.tsx, with their text in line with the box's. The reply and edit composers and the composer's frame all use it, so the frame still gives way without moving. The line never wraps: everything after the model keeps its width, so a long model name is cut short rather than pushing the effort onto a second line. ModelSelect's tooltip is now the chosen model's name by default, so the whole name is still a hover away. --- CHANGELOG.md | 1 + .../conversation/components/Composer.tsx | 6 +++++- .../conversation/components/ComposerFrame.tsx | 21 +++++++++++-------- .../conversation/components/EditComposer.tsx | 6 ++++-- .../conversation/components/ReplyComposer.tsx | 13 +++++++----- .../components/composer-parts.tsx | 17 +++++++++++++-- .../models/components/EffortSelect.tsx | 2 +- .../models/components/ModelSelect.tsx | 6 ++++-- apps/web/src/shared/ui/Select.tsx | 4 ++-- specs/web-ui.md | 4 ++-- 10 files changed, 54 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac229b..164dbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or - Screen readers read a setting's description and any problem found with it along with its field, and a field the server found a problem with rings red. - When a plugin's settings can't be read, the message beside Reset is in red, as the other errors in a card are. - The Tags section in the sidebar unfolds and folds as a reply's sources and steps do, rather than jumping open and shut. +- The composer's model and thinking effort choices moved out of the card into a quiet line under it, leaving only Attach and Send beside the box. A long model name is cut short there rather than pushing the effort onto a second line, and hovering it shows the whole name. ## [0.5.1] - 2026-10-22 diff --git a/apps/web/src/features/conversation/components/Composer.tsx b/apps/web/src/features/conversation/components/Composer.tsx index 6863111..cd74700 100644 --- a/apps/web/src/features/conversation/components/Composer.tsx +++ b/apps/web/src/features/conversation/components/Composer.tsx @@ -45,12 +45,17 @@ import { * * * + * + * + * + * * * */ export { ComposerCard as Card, + ComposerSettings as Settings, ComposerToolbar as Toolbar, StopButton as Stop, } from './composer-parts.tsx' @@ -329,7 +334,6 @@ export function Model() { models={draft.models} value={draft.selected} onChange={(next) => draft.form.setFieldValue('model', next)} - title="Model" none={draft.defaultOption} /> ) diff --git a/apps/web/src/features/conversation/components/ComposerFrame.tsx b/apps/web/src/features/conversation/components/ComposerFrame.tsx index 0c085cc..4c2a297 100644 --- a/apps/web/src/features/conversation/components/ComposerFrame.tsx +++ b/apps/web/src/features/conversation/components/ComposerFrame.tsx @@ -5,6 +5,7 @@ import { AttachButton, ComposerCard, ComposerDock, + ComposerSettings, ComposerToolbar, MessageBox, messagePlaceholder, @@ -38,18 +39,20 @@ export function ComposerFrame(props: Props) { - {}} - /> + + {}} + /> + ) } diff --git a/apps/web/src/features/conversation/components/EditComposer.tsx b/apps/web/src/features/conversation/components/EditComposer.tsx index 852a0d9..1cb54c4 100644 --- a/apps/web/src/features/conversation/components/EditComposer.tsx +++ b/apps/web/src/features/conversation/components/EditComposer.tsx @@ -42,11 +42,13 @@ export function EditComposer({ ref, ...props }: Props) { - - + + + + diff --git a/apps/web/src/features/conversation/components/ReplyComposer.tsx b/apps/web/src/features/conversation/components/ReplyComposer.tsx index 6a08ac7..190c0cc 100644 --- a/apps/web/src/features/conversation/components/ReplyComposer.tsx +++ b/apps/web/src/features/conversation/components/ReplyComposer.tsx @@ -26,9 +26,10 @@ interface Props { /** * The message box for a new message, starting a chat or replying at the end of its branch: one * white card floating at the bottom of the panel, over the conversation fading under it, with - * quotes and attachments at its top, the text, and a toolbar of Attach, the model and effort, and - * Send, which nests in the card's corner as the main action. While a reply runs, Stop takes Send's - * place. It carries on from the frame that stood in for it while the page loaded. + * quotes and attachments at its top, the text, and a toolbar of Attach and Send, which nests in the + * card's corner as the main action, and the model and effort in a quiet line under the card. While + * a reply runs, Stop takes Send's place. It carries on from the frame that stood in for it while + * the page loaded. */ // The ref is kept out of `props`: React Compiler treats an object holding a ref as one, so reading // any other prop in render would stop it memoizing the composer. @@ -55,11 +56,13 @@ export function ReplyComposer({ ref, ...props }: Props) { - - {props.onStop ? : } + + + + diff --git a/apps/web/src/features/conversation/components/composer-parts.tsx b/apps/web/src/features/conversation/components/composer-parts.tsx index 17fc27a..d92e28a 100644 --- a/apps/web/src/features/conversation/components/composer-parts.tsx +++ b/apps/web/src/features/conversation/components/composer-parts.tsx @@ -74,13 +74,13 @@ export function MessageBox(props: ComponentProps<'textarea'>) { ) } -/** The toolbar along the card's foot: Attach, the model and effort, and Send. */ +/** The toolbar along the card's foot: Attach at its start and Send in its corner. */ export function ComposerToolbar(props: ChildrenProps) { return (
{props.children} @@ -88,6 +88,19 @@ export function ComposerToolbar(props: ChildrenProps) { ) } +/** + * The model and effort, a quiet line under the card, its text in line with the box's. It stays one + * line, where everything after the first part, the model, keeps its width, so a long model name + * cuts short rather than pushing the effort under it. + */ +export function ComposerSettings(props: ChildrenProps) { + return ( +
+ {props.children} +
+ ) +} + /** A round button at the toolbar's end or start, nesting in the composer's corner. */ const round = 'inline-grid size-composer-button flex-none place-items-center rounded-full p-0' diff --git a/apps/web/src/features/models/components/EffortSelect.tsx b/apps/web/src/features/models/components/EffortSelect.tsx index ec0f3ea..2468b9e 100644 --- a/apps/web/src/features/models/components/EffortSelect.tsx +++ b/apps/web/src/features/models/components/EffortSelect.tsx @@ -62,7 +62,7 @@ interface Props { 'aria-label'?: string /** What the option for no level is called where it shows, and who decides then. */ auto: AutoEffort - /** Whether the button is quiet text in a toolbar, as in the composer. */ + /** Whether the button is quiet text, as in the line under the composer. */ quiet?: boolean className?: string } diff --git a/apps/web/src/features/models/components/ModelSelect.tsx b/apps/web/src/features/models/components/ModelSelect.tsx index cdd2eee..55e9e94 100644 --- a/apps/web/src/features/models/components/ModelSelect.tsx +++ b/apps/web/src/features/models/components/ModelSelect.tsx @@ -20,8 +20,9 @@ interface Props { none?: None id?: string 'aria-label'?: string + /** The button's tooltip, by default the chosen model's name, which the button may cut short. */ title?: string - /** How the button looks: a field, or quiet text in a toolbar, as in the composer. */ + /** How the button looks: a field, or quiet text, as in the line under the composer. */ quiet?: boolean /** Whether the models are on their way, which holds the button with a line in place of a name. */ loading?: boolean @@ -41,6 +42,7 @@ export function ModelSelect(props: Props) { ...props.models.map((m) => ({ value: keyOf(m), label: m.name, keywords: keyOf(m) })), ] const key = value ? keyOf(value) : '' + const name = options.find((option) => option.value === key)?.label ?? props.none?.label if (props.loading) return (