From 3f03e8daa11e37a5e6e851f90af2a24c01c541e9 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Mon, 3 Aug 2026 09:27:12 +0800 Subject: [PATCH] feat(web): add expandable message composer (#1319) * feat(web): add expandable message composer * fix(web): preserve composer selection when expanding * fix(web): keep composer toolbar actions tappable * fix(web): preserve composer escape behavior * fix(web): apply overflow-safe toolbar alignment --- .../AssistantChat/ComposerButtons.test.tsx | 110 ++++++++++++++- .../AssistantChat/ComposerButtons.tsx | 111 +++++++++++++-- .../HappyComposer.expandSelection.test.tsx | 105 ++++++++++++++ .../HappyComposer.richBridge.test.tsx | 22 ++- .../AssistantChat/HappyComposer.tsx | 133 +++++++++++++++--- .../settings/ComposerToolbarLayoutControl.tsx | 1 + .../hooks/useComposerToolbarLayout.test.ts | 3 +- web/src/hooks/useComposerToolbarLayout.ts | 1 + web/src/lib/locales/en.ts | 3 + web/src/lib/locales/zh-CN.ts | 3 + 10 files changed, 460 insertions(+), 32 deletions(-) create mode 100644 web/src/components/AssistantChat/HappyComposer.expandSelection.test.tsx diff --git a/web/src/components/AssistantChat/ComposerButtons.test.tsx b/web/src/components/AssistantChat/ComposerButtons.test.tsx index 183b970f..b2663df4 100644 --- a/web/src/components/AssistantChat/ComposerButtons.test.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.test.tsx @@ -1,8 +1,32 @@ -import type { ReactElement } from 'react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + AssistantRuntimeProvider, + type ChatModelAdapter, + useLocalRuntime, +} from '@assistant-ui/react' +import type { ReactElement, ReactNode } from 'react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' -import { DictationButton, UnifiedButton } from './ComposerButtons' +import { + ComposerButtons, + ComposerExpandButton, + DictationButton, + getComposerToolbarJustifyContent, + UnifiedButton, +} from './ComposerButtons' + +const adapter: ChatModelAdapter = { + async *run() {}, +} + +function RuntimeProviders(props: { children: ReactNode }) { + const runtime = useLocalRuntime(adapter) + return ( + + {props.children} + + ) +} function renderInProviders(ui: ReactElement) { return render({ui}) @@ -103,3 +127,83 @@ describe('DictationButton', () => { expect(onVoiceToggle).toHaveBeenCalledOnce() }) }) + +describe('ComposerExpandButton', () => { + afterEach(() => { + cleanup() + }) + + it('announces and triggers expansion', () => { + const onToggle = vi.fn() + renderInProviders() + + const button = getButton('Expand message editor') + expect(button.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(button) + expect(onToggle).toHaveBeenCalledOnce() + }) + + it('announces the collapse action while expanded', () => { + renderInProviders( {}} />) + + const button = getButton('Collapse message editor') + expect(button.getAttribute('aria-pressed')).toBe('true') + expect(button.className).toContain('text-[var(--app-link)]') + }) +}) + +describe('ComposerButtons responsive toolbar', () => { + afterEach(cleanup) + + it('keeps toolbar actions non-shrinking inside a horizontal scroll area', () => { + const noop = () => {} + render( + +
+ +
+
, + ) + + const toolbar = screen.getByTestId('composer-toolbar-items') + expect(toolbar.className).toContain('overflow-x-auto') + const toolbarButtons = within(toolbar).getAllByRole('button') + expect(toolbarButtons.length).toBeGreaterThanOrEqual(8) + for (const button of toolbarButtons) { + expect(button.closest('.shrink-0')).not.toBeNull() + } + }) + + it('uses overflow-safe alignment for centered and right-aligned layouts', () => { + expect(getComposerToolbarJustifyContent('center')).toBe('safe center') + expect(getComposerToolbarJustifyContent('right')).toBe('safe end') + expect(getComposerToolbarJustifyContent('left')).toBe('flex-start') + expect(getComposerToolbarJustifyContent('split')).toBe('flex-start') + }) +}) diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 2701502f..61fa9213 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -18,7 +18,11 @@ function OrderedToolbarItems(props: { layout: ComposerToolbarLayout; children: R (child): child is ReactElement<{ item: ComposerToolbarItemId; children: ReactNode }> => isValidElement(child), ) const slotsByItem = new Map(slots.map((slot) => [slot.props.item, slot])) - const renderItems = (items: ComposerToolbarItemId[]) => items.map((item) => slotsByItem.get(item) ?? null) + const renderItems = (items: ComposerToolbarItemId[]) => items.map((item) => { + const slot = slotsByItem.get(item) + if (!slot || slot.props.children == null) return null + return
{slot}
+ }) if (props.layout.mode === 'split') { return <>{renderItems(props.layout.left)}