From 1eae54ad5c323abf9b299caf3bf951ade74e556c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 31 Jul 2026 04:18:01 +0800 Subject: [PATCH] feat(desktop): send from a round arrow button in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's send button was a 112px pill reading "run" with a trailing arrow, in a toolbar where every other control is already icon-only and where a model picker and a location chip have to fit beside it. It is now the round up-arrow the rest of the category uses. The arrow points into the transcript the message is going to, which is what lets it read as send with no word next to it; the name lives in aria-label and the tooltip, at every width. Both composers change together — the in-session one and EmptySession's. They were already deliberate mirrors of each other, and the note in ChatInput recording why is now the note explaining the shape. Adds shape="circle" to Button rather than a tone to IconButton, which already has a circle and forces a label. Two things ruled IconButton out. It dims to opacity-50 when disabled, and half-transparent ink over the page ground reads as "still loading" rather than "not available" — the exact reason primary carries an opaque disabled fill, and the send button sits disabled whenever the input is empty. And --color-inverse-surface is already btn-primary's own --cc-t1, so reproducing primary's colors there would have forked that definition in two, past the token contrast guards. No new colors: primary is --cc-t1 on --cc-bg, which inverts on its own. The four paper themes give a dark ink circle with a pale arrow, dark and ink-blue a pale circle with a dark one. Measured across all six, arrow-on-circle runs 12.70:1 to 16.98:1, and hover resolves to --color-brand throughout. The radius moves out of BASE_CLASSES so exactly one rounded-* is ever emitted; the component does no Tailwind conflict resolution, so keeping both would have left the shape to stylesheet order rather than to the prop (AGENTS.md 3.6). A test pins that. Drops iconOnlyAction and the 610px threshold behind it. It existed to shed the run button's label before the location degraded, and there is no label left to shed, so the location is now the only thing that degrades. Also covers the stop button, which had no test at all: send and stop are one control that swaps role, so a round send becoming a pill on stop would shift the whole toolbar every time a turn starts. --- .../src/components/chat/ChatInput.test.tsx | 42 +++++++++++- desktop/src/components/chat/ChatInput.tsx | 67 ++++++++++--------- desktop/src/components/ui/Button.test.tsx | 34 ++++++++++ desktop/src/components/ui/Button.tsx | 48 ++++++++++++- desktop/src/pages/EmptySession.tsx | 15 +++-- 5 files changed, 161 insertions(+), 45 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 48f6ae6e..a3286fc6 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -904,18 +904,54 @@ describe('ChatInput file mentions', () => { // `aria-label` and tooltip, while dropping the location costs a whole line // and the directory the turn runs in. So the label goes first, at a width // where keeping both would squeeze the location down to its ellipsis. - it('drops the run button label before the run location as the column narrows', async () => { + it('keeps the same circle when it turns into the stop button mid-turn', async () => { + // Send and stop are one control that swaps role, so the shape has to + // survive the swap — a round send that becomes a pill on stop would move + // the whole toolbar every time a turn starts. Only the fill and the glyph + // change. + stubComposerColumnWidth(700) + + render() + + const send = screen.getByRole('button', { name: 'Run' }) + expect(send).toHaveClass('rounded-full', 'h-8', 'w-8') + expect(send).toHaveTextContent('arrow_upward') + + await act(async () => { + useChatStore.setState({ + sessions: { + ...useChatStore.getState().sessions, + [sessionId]: { ...useChatStore.getState().sessions[sessionId]!, chatState: 'streaming' }, + }, + }) + }) + + const stop = screen.getByRole('button', { name: 'Stop' }) + expect(stop).toHaveClass('rounded-full', 'h-8', 'w-8') + expect(stop).toHaveTextContent('stop') + expect(stop).not.toBeDisabled() + }) + + // This used to assert that the run button shed its label before the location + // moved. The button has no label to shed any more — it is one round icon at + // every width — so what needs pinning is that it does *not* change with the + // column, leaving the location as the only thing that degrades (next test). + it('keeps the send button a fixed circle as the column narrows', async () => { const column = stubComposerColumnWidth(700) render() expect(await screen.findByTestId('run-location-readonly')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Run' })).toHaveTextContent('Run') + const wide = screen.getByRole('button', { name: 'Run' }) + expect(wide).not.toHaveTextContent('Run') + expect(wide).toHaveClass('rounded-full', 'h-8', 'w-8') column.resizeTo(580) expect(screen.getByTestId('run-location-readonly')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Run' })).not.toHaveTextContent('Run') + const narrow = screen.getByRole('button', { name: 'Run' }) + expect(narrow).not.toHaveTextContent('Run') + expect(narrow).toHaveClass('rounded-full', 'h-8', 'w-8') }) it('moves the run location out of the toolbar as the column is dragged narrow', async () => { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 5e45061a..2a577f6e 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -72,19 +72,17 @@ const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] * with no panel at all, yet the panel-keyed rule sent the wide one to the * narrow layout and kept the narrow one wide. * - * The numbers come off the shipped toolbar with the longest mode label + * The number comes off the shipped toolbar with the longest mode label * ("Ask permissions" / 询问权限): the leading group measures 193px and the - * trailing cluster 361px, so the full row needs a 610px column and the same - * row with an icon-only run button — the button is 112px labelled, 32px not — - * needs 530px. + * trailing cluster 361px, which with the 32px round send button needs a 530px + * column. * - * They degrade in that order on purpose. The location is information and the - * run button's label is not: the icon keeps its `aria-label` and tooltip, so - * dropping the word costs nothing a narrow column can't spare, while dropping - * the location costs a line of layout and the directory the turn will run in. + * There used to be a second, wider threshold here that dropped the send + * button's label before the location went. The send button has no label to + * drop any more — it is the same 32px circle at every width — so the location + * is the only thing left that degrades. */ const TOOLBAR_LOCATION_MIN_WIDTH = 530 -const TOOLBAR_LABELLED_ACTION_MIN_WIDTH = 610 function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment { return { @@ -234,15 +232,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro // and the toolbar's edge-to-edge band belong to the panel-beside-the-composer // and mobile layouts regardless of how much room those layouts got. // - // `useCompactControls` and `iconOnlyAction` are about room, so they ask the - // column how wide it is — at two different widths, so the run button's label - // goes before the location does. Until a measurement lands (jsdom, first - // paint) both defer to the caller's `compact`, which keeps the - // pre-measurement frame from flashing the wrong layout. + // `useCompactControls` is about room, so it asks the column how wide it is. + // Until a measurement lands (jsdom, first paint) it defers to the caller's + // `compact`, which keeps the pre-measurement frame from flashing the wrong + // layout. const useCompactChrome = compact || isMobileComposer const fitsAtLeast = (minWidth: number) => shellWidth === null ? !compact : shellWidth >= minWidth const useCompactControls = isMobileComposer || !fitsAtLeast(TOOLBAR_LOCATION_MIN_WIDTH) - const iconOnlyAction = isMobileComposer || !fitsAtLeast(TOOLBAR_LABELLED_ACTION_MIN_WIDTH) const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '') // The run location lives in the toolbar on the wide desktop composer, and it // stays there for the whole session: editable while the session is still a @@ -1409,37 +1405,42 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro fluid={isMobileComposer} /> )} - {/* Same component and same icon placement as EmptySession's run - button. The two rendered mirror images of each other until it - was spotted in a walkthrough — the arrow led here and trailed - there, on what is the same button to the user. */} + {/* Same component, shape and icon as EmptySession's send button. + The two rendered mirror images of each other until it was + spotted in a walkthrough — the arrow led here and trailed + there, on what is the same button to the user. + + A round icon-only target rather than a labelled pill: the + label said "run" while every other composer control on the row + is already icon-only, and the width it cost was the widest + fixed block in a toolbar that has to fit a model picker and a + location chip. The arrow points *up* — into the transcript the + message is being sent to — which is also what makes it read as + send without a word next to it. Dropping the label is why the + name now lives only in `aria-label`, on both breakpoints. */} + /> diff --git a/desktop/src/components/ui/Button.test.tsx b/desktop/src/components/ui/Button.test.tsx index 34113d6f..79538c37 100644 --- a/desktop/src/components/ui/Button.test.tsx +++ b/desktop/src/components/ui/Button.test.tsx @@ -33,6 +33,40 @@ describe('Button', () => { expect(container.firstElementChild?.className).toContain('focus-visible:ring-2') }) + describe('shape="circle"', () => { + it.each(SIZES)('matches width to the size=%s height so it is round, not a pill', (size) => { + const { container } = render( + ) + const className = container.firstElementChild?.className ?? '' + expect(className.match(/\brounded-\S+/g)).toEqual(['rounded-[var(--radius-md)]']) + expect(className).toMatch(/\bpx-/) + }) + }) + it('disables itself and announces busy while loading', () => { render() const button = screen.getByRole('button', { name: 'Saving' }) diff --git a/desktop/src/components/ui/Button.tsx b/desktop/src/components/ui/Button.tsx index 6677ffe1..0f85d708 100644 --- a/desktop/src/components/ui/Button.tsx +++ b/desktop/src/components/ui/Button.tsx @@ -33,9 +33,25 @@ export type ButtonVariant = */ export type ButtonSize = 'xs' | 'sm' | 'base' | 'md' | 'lg' +/** + * `circle` collapses the button to a round icon-only target: square, no + * horizontal padding, fully rounded. + * + * It is a shape on `Button` rather than a new tone on `IconButton` because + * what the composer's send button needs is the *primary* button — ink at rest, + * terracotta on hover, and above all an opaque disabled fill. `IconButton` + * dims to `opacity-50` instead, and half-transparent ink over the page ground + * reads as "still loading" rather than "not available" — the exact reason + * `DISABLED_FILL` exists below. Reproducing primary's colors on `IconButton` + * would have forked that definition in two, past the token contrast guards. + */ +export type ButtonShape = 'default' | 'circle' + export type ButtonProps = ButtonHTMLAttributes & { variant?: ButtonVariant size?: ButtonSize + /** `circle` requires `aria-label`: it renders the icon alone, with no text. */ + shape?: ButtonShape /** Swaps `icon` for a spinner and disables the button. Also sets `aria-busy`. */ loading?: boolean icon?: ReactNode @@ -116,10 +132,34 @@ const SIZE_PX: Record = { lg: 'px-5', } +/** + * Widths for `shape="circle"`, one per size's height in SIZE_CLASSES. A circle + * is only a circle while the two agree, so these are not free to drift. + */ +const SIZE_SQUARE: Record = { + xs: 'w-5', + sm: 'w-6', + base: 'w-8', + md: 'w-9', + lg: 'w-10', +} + const SPINNER_SIZE: Record = { xs: 11, sm: 12, base: 14, md: 16, lg: 16 } +/** + * The radius lives here rather than in BASE_CLASSES for the same reason `link` + * pulls `px` out of the size class: emitting both `rounded-[var(--radius-md)]` + * and `rounded-full` would leave the winner to stylesheet order rather than to + * the shape that was asked for (see components/AGENTS.md §3.6). Exactly one + * radius may ever be emitted. + */ +const SHAPE_RADIUS: Record = { + default: 'rounded-[var(--radius-md)]', + circle: 'rounded-full', +} + const BASE_CLASSES = [ - 'inline-flex items-center justify-center rounded-[var(--radius-md)]', + 'inline-flex items-center justify-center', 'font-medium cursor-pointer', // The handoff pins one motion curve on every button: .16s on paint, .14s on // transform. `transition-colors` alone would leave the hover lift snapping. @@ -151,6 +191,7 @@ const BASE_CLASSES = [ export const Button = forwardRef(function Button({ variant = 'primary', size = 'md', + shape = 'default', loading = false, icon, iconPosition = 'start', @@ -172,9 +213,12 @@ export const Button = forwardRef(function Button aria-busy={loading || undefined} className={cx( BASE_CLASSES, + SHAPE_RADIUS[shape], VARIANT_CLASSES[variant], SIZE_CLASSES[size], - variant !== 'link' && SIZE_PX[size], + // A circle carries no label, so it takes a matching width instead of + // horizontal padding. + shape === 'circle' ? SIZE_SQUARE[size] : variant !== 'link' && SIZE_PX[size], block && 'w-full', className, )} diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 9d2d0bff..e90fb19e 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -780,19 +780,20 @@ export function EmptySession() { compact={isMobileComposer} /> + {/* Kept identical to ChatInput's send button — same + component, shape, size and icon. See the note there for + why the label went away. */} + title={t('common.run')} + className={`shrink-0 ${isMobileComposer ? 'h-11 w-11' : ''}`} + icon={arrow_upward} + />