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
This commit is contained in:
Ananovo
2026-08-03 09:27:12 +08:00
committed by GitHub
parent aff2c1225d
commit 3f03e8daa1
10 changed files with 460 additions and 32 deletions
@@ -1,8 +1,32 @@
import type { ReactElement } from 'react' import {
import { cleanup, fireEvent, render, screen } from '@testing-library/react' 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 { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context' 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 (
<AssistantRuntimeProvider runtime={runtime}>
<I18nProvider>{props.children}</I18nProvider>
</AssistantRuntimeProvider>
)
}
function renderInProviders(ui: ReactElement) { function renderInProviders(ui: ReactElement) {
return render(<I18nProvider>{ui}</I18nProvider>) return render(<I18nProvider>{ui}</I18nProvider>)
@@ -103,3 +127,83 @@ describe('DictationButton', () => {
expect(onVoiceToggle).toHaveBeenCalledOnce() expect(onVoiceToggle).toHaveBeenCalledOnce()
}) })
}) })
describe('ComposerExpandButton', () => {
afterEach(() => {
cleanup()
})
it('announces and triggers expansion', () => {
const onToggle = vi.fn()
renderInProviders(<ComposerExpandButton expanded={false} onToggle={onToggle} />)
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(<ComposerExpandButton expanded onToggle={() => {}} />)
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(
<RuntimeProviders>
<div style={{ width: 320 }}>
<ComposerButtons
canSend
controlsDisabled={false}
showSettingsButton
onSettingsToggle={noop}
expanded={false}
onExpandedToggle={noop}
showTerminalButton
terminalDisabled={false}
terminalLabel="Terminal"
onTerminal={noop}
showAbortButton
abortDisabled={false}
isAborting={false}
onAbort={noop}
showSwitchButton
switchDisabled={false}
isSwitching={false}
onSwitch={noop}
voiceEnabled
dictationEnabled
voiceStatus="disconnected"
onVoiceToggle={noop}
onSend={noop}
onSchedule={noop}
onScratchlistToggle={noop}
/>
</div>
</RuntimeProviders>,
)
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')
})
})
@@ -18,7 +18,11 @@ function OrderedToolbarItems(props: { layout: ComposerToolbarLayout; children: R
(child): child is ReactElement<{ item: ComposerToolbarItemId; children: ReactNode }> => isValidElement(child), (child): child is ReactElement<{ item: ComposerToolbarItemId; children: ReactNode }> => isValidElement(child),
) )
const slotsByItem = new Map(slots.map((slot) => [slot.props.item, slot])) 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 <div key={item} className="shrink-0">{slot}</div>
})
if (props.layout.mode === 'split') { if (props.layout.mode === 'split') {
return <>{renderItems(props.layout.left)}<span className="flex-1" aria-hidden="true" />{renderItems(props.layout.right)}</> return <>{renderItems(props.layout.left)}<span className="flex-1" aria-hidden="true" />{renderItems(props.layout.right)}</>
@@ -26,6 +30,16 @@ function OrderedToolbarItems(props: { layout: ComposerToolbarLayout; children: R
return <>{renderItems([...props.layout.left, ...props.layout.right])}</> return <>{renderItems([...props.layout.left, ...props.layout.right])}</>
} }
export function getComposerToolbarJustifyContent(
mode: ComposerToolbarLayout['mode'],
): 'safe center' | 'safe end' | 'flex-start' {
return mode === 'center'
? 'safe center'
: mode === 'right'
? 'safe end'
: 'flex-start'
}
function ChevronIcon() { function ChevronIcon() {
return <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M2.5 3.75L5 6.25L7.5 3.75" /></svg> return <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M2.5 3.75L5 6.25L7.5 3.75" /></svg>
} }
@@ -171,6 +185,77 @@ function AttachmentIcon() {
) )
} }
function ComposerExpandIcon(props: { expanded: boolean }) {
return props.expanded ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 3v5H3" />
<path d="m3 3 5 5" />
<path d="M16 3v5h5" />
<path d="m21 3-5 5" />
<path d="M8 21v-5H3" />
<path d="m3 21 5-5" />
<path d="M16 21v-5h5" />
<path d="m21 21-5-5" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 3H3v5" />
<path d="m3 3 5 5" />
<path d="M16 3h5v5" />
<path d="m21 3-5 5" />
<path d="M8 21H3v-5" />
<path d="m3 21 5-5" />
<path d="M16 21h5v-5" />
<path d="m21 21-5-5" />
</svg>
)
}
export function ComposerExpandButton(props: {
expanded: boolean
onToggle: () => void
}) {
const { t } = useTranslation()
const label = props.expanded ? t('composer.collapse') : t('composer.expand')
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={props.expanded}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
props.expanded
? 'bg-[var(--app-bg)] text-[var(--app-link)]'
: 'text-[var(--app-fg)]/60 hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]'
}`}
onClick={props.onToggle}
>
<ComposerExpandIcon expanded={props.expanded} />
</button>
)
}
function AbortIcon(props: { spinning: boolean }) { function AbortIcon(props: { spinning: boolean }) {
if (props.spinning) { if (props.spinning) {
return ( return (
@@ -248,6 +333,7 @@ export function ComposerToolbarItemPreview(props: { item: ComposerToolbarItemId;
switch (props.item) { switch (props.item) {
case 'attachment': return <AttachmentIcon /> case 'attachment': return <AttachmentIcon />
case 'settings': return <SettingsIcon /> case 'settings': return <SettingsIcon />
case 'expand': return <ComposerExpandIcon expanded={false} />
case 'terminal': return <TerminalIcon /> case 'terminal': return <TerminalIcon />
case 'abort': return <AbortIcon spinning={false} /> case 'abort': return <AbortIcon spinning={false} />
case 'switch': return <SwitchToRemoteIcon /> case 'switch': return <SwitchToRemoteIcon />
@@ -515,6 +601,8 @@ export function ComposerButtons(props: {
controlsDisabled: boolean controlsDisabled: boolean
showSettingsButton: boolean showSettingsButton: boolean
onSettingsToggle: () => void onSettingsToggle: () => void
expanded: boolean
onExpandedToggle: () => void
showTerminalButton: boolean showTerminalButton: boolean
terminalDisabled: boolean terminalDisabled: boolean
terminalLabel: string terminalLabel: string
@@ -567,15 +655,15 @@ export function ComposerButtons(props: {
const hasSchedule = props.pendingSchedule != null const hasSchedule = props.pendingSchedule != null
const hasAttachments = props.hasAttachments ?? false const hasAttachments = props.hasAttachments ?? false
const toolbarAlignmentClass = layout.mode === 'center' const toolbarJustifyContent = getComposerToolbarJustifyContent(layout.mode)
? 'justify-center'
: layout.mode === 'right'
? 'justify-end'
: 'justify-start'
return ( return (
<div className="flex items-center gap-1 px-2 pb-2"> <div className="flex shrink-0 items-center gap-1 px-2 pb-2">
<div className={`flex min-w-0 flex-1 items-center gap-1 ${toolbarAlignmentClass}`}> <div
data-testid="composer-toolbar-items"
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
style={{ justifyContent: toolbarJustifyContent }}
>
<OrderedToolbarItems layout={layout}> <OrderedToolbarItems layout={layout}>
<ToolbarItemSlot item="attachment"> <ToolbarItemSlot item="attachment">
<ComposerPrimitive.AddAttachment <ComposerPrimitive.AddAttachment
@@ -603,6 +691,13 @@ export function ComposerButtons(props: {
) : null} ) : null}
</ToolbarItemSlot> </ToolbarItemSlot>
<ToolbarItemSlot item="expand">
<ComposerExpandButton
expanded={props.expanded}
onToggle={props.onExpandedToggle}
/>
</ToolbarItemSlot>
<ToolbarItemSlot item="piModel"> <ToolbarItemSlot item="piModel">
{props.piModelLabel ? ( {props.piModelLabel ? (
<button <button
@@ -0,0 +1,105 @@
import {
AssistantRuntimeProvider,
type ChatModelAdapter,
useLocalRuntime,
} from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HappyComposer } from './HappyComposer'
vi.mock('@/components/AssistantChat/ComposerButtons', () => ({
ComposerButtons: (props: { expanded: boolean; onExpandedToggle: () => void }) => (
<button
type="button"
aria-label={props.expanded ? 'Collapse message editor' : 'Expand message editor'}
onClick={props.onExpandedToggle}
/>
),
}))
vi.mock('@/components/AssistantChat/StatusBar', () => ({ StatusBar: () => null }))
vi.mock('@/hooks/useComposerDraft', () => ({ useComposerDraft: () => {} }))
vi.mock('@/hooks/usePlatform', () => ({
usePlatform: () => ({
isTelegram: false,
isTouch: false,
haptic: {
impact: () => {},
notification: () => {},
selection: () => {},
},
}),
}))
vi.mock('@/hooks/usePWAInstall', () => ({
usePWAInstall: () => ({
installState: 'idle',
canInstall: false,
canInstallIOS: false,
isStandalone: false,
isIOS: false,
promptInstall: async () => false,
dismissInstall: () => {},
}),
}))
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({
t: (key: string) => key === 'misc.typeAMessage' ? 'Type a message' : key,
}),
}))
const adapter: ChatModelAdapter = {
async *run() {},
}
function TestRuntime() {
const runtime = useLocalRuntime(adapter)
return (
<AssistantRuntimeProvider runtime={runtime}>
<HappyComposer />
</AssistantRuntimeProvider>
)
}
describe('HappyComposer plain-text expansion', () => {
beforeEach(() => {
localStorage.clear()
localStorage.setItem('hapi.composer.richMentions', '0')
})
it('preserves draft text and selection across expand and collapse', async () => {
render(<TestRuntime />)
const draft = 'A long draft with a selection in the middle that must survive both editor layout changes.'
const collapsedInput = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(collapsedInput, { target: { value: draft } })
collapsedInput.setSelectionRange(12, 36, 'forward')
fireEvent.click(screen.getByRole('button', { name: 'Expand message editor' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Collapse message editor' })).toBeInTheDocument()
const expandedInput = screen.getByRole('textbox') as HTMLTextAreaElement
expect(expandedInput).not.toBe(collapsedInput)
expect(expandedInput.value).toBe(draft)
expect(expandedInput.selectionStart).toBe(12)
expect(expandedInput.selectionEnd).toBe(36)
expect(expandedInput.selectionDirection).toBe('forward')
expect(document.activeElement).toBe(expandedInput)
})
const expandedInput = screen.getByRole('textbox') as HTMLTextAreaElement
expandedInput.setSelectionRange(42, 67, 'backward')
fireEvent.click(screen.getByRole('button', { name: 'Collapse message editor' }))
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Expand message editor' })).toBeInTheDocument()
const nextCollapsedInput = screen.getByRole('textbox') as HTMLTextAreaElement
expect(nextCollapsedInput).not.toBe(expandedInput)
expect(nextCollapsedInput.value).toBe(draft)
expect(nextCollapsedInput.selectionStart).toBe(42)
expect(nextCollapsedInput.selectionEnd).toBe(67)
expect(nextCollapsedInput.selectionDirection).toBe('backward')
expect(document.activeElement).toBe(nextCollapsedInput)
})
})
})
@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen } from '@testing-library/react' import { act, fireEvent, render, screen } from '@testing-library/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { type TextInputState, useRichComposerBridge } from './HappyComposer' import { getComposerEscapeAction, type TextInputState, useRichComposerBridge } from './HappyComposer'
const events = vi.hoisted(() => [] as string[]) const events = vi.hoisted(() => [] as string[])
@@ -78,3 +78,23 @@ describe('useRichComposerBridge', () => {
]) ])
}) })
}) })
describe('getComposerEscapeAction', () => {
it('clears suggestions before aborting, and aborts before collapsing', () => {
expect(getComposerEscapeAction({
hasSuggestions: true,
threadIsRunning: true,
isExpanded: true,
})).toBe('clearSuggestions')
expect(getComposerEscapeAction({
hasSuggestions: false,
threadIsRunning: true,
isExpanded: true,
})).toBe('abort')
expect(getComposerEscapeAction({
hasSuggestions: false,
threadIsRunning: false,
isExpanded: true,
})).toBe('collapse')
})
})
@@ -56,6 +56,17 @@ export interface TextInputState {
selection: { start: number; end: number } selection: { start: number; end: number }
} }
export function getComposerEscapeAction(input: {
hasSuggestions: boolean
threadIsRunning: boolean
isExpanded: boolean
}): 'clearSuggestions' | 'abort' | 'collapse' | null {
if (input.hasSuggestions) return 'clearSuggestions'
if (input.threadIsRunning) return 'abort'
if (input.isExpanded) return 'collapse'
return null
}
/** /**
* One rejected send. `id` is bumped per failure so two failures with the * One rejected send. `id` is bumped per failure so two failures with the
* same `text` still trigger a fresh restore (the dedupe key is the id, not * same `text` still trigger a fresh restore (the dedupe key is the id, not
@@ -382,6 +393,7 @@ export function HappyComposer(props: {
text: '', text: '',
selection: { start: 0, end: 0 } selection: { start: 0, end: 0 }
}) })
const [isExpanded, setIsExpanded] = useState(false)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showPiModelPanel, setShowPiModelPanel] = useState(false) const [showPiModelPanel, setShowPiModelPanel] = useState(false)
const [showPiThinkingPanel, setShowPiThinkingPanel] = useState(false) const [showPiThinkingPanel, setShowPiThinkingPanel] = useState(false)
@@ -608,6 +620,39 @@ export function HappyComposer(props: {
} }
}, [platformHaptic]) }, [platformHaptic])
const handleExpandedToggle = useCallback(() => {
const currentInput = textareaRef.current
const selection = currentInput ? {
start: currentInput.selectionStart,
end: currentInput.selectionEnd,
direction: currentInput.selectionDirection,
} : null
setIsExpanded((expanded) => !expanded)
haptic('light')
setTimeout(() => {
if (richMentionsEnabled) {
richInputRef.current?.focus()
return
}
const input = textareaRef.current
if (!input) return
try {
input.focus({ preventScroll: true })
} catch {
input.focus()
}
if (selection) {
const maxOffset = input.value.length
input.setSelectionRange(
Math.min(selection.start, maxOffset),
Math.min(selection.end, maxOffset),
selection.direction,
)
}
}, 0)
}, [haptic, richMentionsEnabled])
const handleSuggestionSelect = useCallback((index: number) => { const handleSuggestionSelect = useCallback((index: number) => {
const suggestion = suggestions[index] const suggestion = suggestions[index]
if (!suggestion) return if (!suggestion) return
@@ -873,17 +918,21 @@ export function HappyComposer(props: {
handleSuggestionSelect(indexToSelect) handleSuggestionSelect(indexToSelect)
return return
} }
if (key === 'Escape') {
e.preventDefault()
clearSuggestions()
return
}
} }
if (key === 'Escape' && threadIsRunning) { if (key === 'Escape') {
e.preventDefault() const action = getComposerEscapeAction({
handleAbort() hasSuggestions: suggestions.length > 0,
return threadIsRunning,
isExpanded,
})
if (action) {
e.preventDefault()
if (action === 'clearSuggestions') clearSuggestions()
else if (action === 'abort') handleAbort()
else handleExpandedToggle()
return
}
} }
if (key === 'Tab' && e.shiftKey && onPermissionModeChange && permissionModes.length > 0) { if (key === 'Tab' && e.shiftKey && onPermissionModeChange && permissionModes.length > 0) {
@@ -912,6 +961,8 @@ export function HappyComposer(props: {
composerEnterBehavior, composerEnterBehavior,
richMentionsEnabled, richMentionsEnabled,
flushAndSend, flushAndSend,
isExpanded,
handleExpandedToggle,
]) ])
useEffect(() => { useEffect(() => {
@@ -1124,6 +1175,10 @@ export function HappyComposer(props: {
haptic('light') haptic('light')
}, [controlsDisabled, haptic]) }, [controlsDisabled, haptic])
const overlayPositionClass = isExpanded
? 'absolute z-10 bottom-12 mb-2'
: 'absolute z-10 bottom-[100%] mb-2'
const overlays = useMemo(() => { const overlays = useMemo(() => {
// Pi flavor: separate floating panels for model and thinking level. // Pi flavor: separate floating panels for model and thinking level.
// (Pi RPC mode has no runtime permission switching → no permission panel.) // (Pi RPC mode has no runtime permission switching → no permission panel.)
@@ -1134,7 +1189,7 @@ export function HappyComposer(props: {
if (showPiModelPanel && piModels && piModels.length > 0) { if (showPiModelPanel && piModels && piModels.length > 0) {
const currentPiModel = selectedPiModel ?? null const currentPiModel = selectedPiModel ?? null
panels.push( panels.push(
<div key="model" className="absolute bottom-[100%] mb-2 left-2 w-64"> <div key="model" className={`${overlayPositionClass} left-2 w-64`}>
<PiModelPanel <PiModelPanel
models={piModels} models={piModels}
currentModel={currentPiModel ? { provider: currentPiModel.provider, modelId: currentPiModel.modelId } : null} currentModel={currentPiModel ? { provider: currentPiModel.provider, modelId: currentPiModel.modelId } : null}
@@ -1151,7 +1206,7 @@ export function HappyComposer(props: {
// Thinking level panel // Thinking level panel
if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) { if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) {
panels.push( panels.push(
<div key="thinking" className="absolute bottom-[100%] mb-2 left-2 w-48"> <div key="thinking" className={`${overlayPositionClass} left-2 w-48`}>
<PiThinkingLevelPanel <PiThinkingLevelPanel
currentLevel={effort} currentLevel={effort}
reasoning={selectedPiModel?.reasoning} reasoning={selectedPiModel?.reasoning}
@@ -1170,7 +1225,7 @@ export function HappyComposer(props: {
// Non-Pi flavors: original unified gear menu // Non-Pi flavors: original unified gear menu
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) {
return ( return (
<div className="absolute bottom-[100%] mb-2 w-full"> <div className={`${overlayPositionClass} w-full`}>
<FloatingOverlay maxHeight={320}> <FloatingOverlay maxHeight={320}>
{showCollaborationSettings ? ( {showCollaborationSettings ? (
<div className="py-2"> <div className="py-2">
@@ -1482,7 +1537,7 @@ export function HappyComposer(props: {
if (suggestions.length > 0) { if (suggestions.length > 0) {
return ( return (
<div className="absolute bottom-[100%] mb-2 w-full"> <div className={`${overlayPositionClass} w-full`}>
<FloatingOverlay> <FloatingOverlay>
<Autocomplete <Autocomplete
suggestions={suggestions} suggestions={suggestions}
@@ -1535,13 +1590,27 @@ export function HappyComposer(props: {
handleEffortChange, handleEffortChange,
handleServiceTierChange, handleServiceTierChange,
handleSuggestionSelect, handleSuggestionSelect,
overlayPositionClass,
t t
]) ])
const shellClassName = isExpanded
? `z-[60] flex min-h-0 flex-col bg-[var(--app-bg)] px-3 ${bottomPaddingClass} max-sm:fixed max-sm:inset-x-0 max-sm:top-0 max-sm:h-[var(--tg-viewport-stable-height,var(--app-viewport-height,100dvh))] max-sm:pt-[calc(0.5rem+env(safe-area-inset-top))] sm:absolute sm:inset-0 sm:pt-2`
: `bg-[var(--app-bg)] px-3 ${bottomPaddingClass} pt-2`
const innerClassName = isExpanded
? 'mx-auto flex min-h-0 w-full max-w-content flex-1 flex-col'
: 'mx-auto w-full max-w-content'
const rootClassName = isExpanded
? 'relative flex min-h-0 flex-1 flex-col'
: 'relative'
const editorClassName = isExpanded
? 'h-full min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none'
: 'max-h-[7.5rem] min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none'
return ( return (
<div className={`px-3 ${bottomPaddingClass} pt-2 bg-[var(--app-bg)]`}> <div className={shellClassName} data-testid="composer-shell" data-expanded={isExpanded || undefined}>
<div className="mx-auto w-full max-w-content"> <div className={innerClassName}>
<ComposerPrimitive.Root className="relative" onSubmit={handleSubmit}> <ComposerPrimitive.Root className={rootClassName} onSubmit={handleSubmit}>
{overlays} {overlays}
<StatusBar <StatusBar
@@ -1592,16 +1661,22 @@ export function HappyComposer(props: {
<div <div
className={`overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)] ${ className={`overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)] ${
isExpanded ? 'flex min-h-0 flex-1 flex-col' : ''
} ${
sendError ? 'ring-1 ring-red-500' : '' sendError ? 'ring-1 ring-red-500' : ''
}`} }`}
> >
{attachments.length > 0 ? ( {attachments.length > 0 ? (
<div className="flex flex-wrap gap-2 px-4 pt-3"> <div className={`flex flex-wrap gap-2 px-4 pt-3 ${
isExpanded ? 'max-h-[35%] shrink-0 overflow-y-auto' : ''
}`}>
<ComposerPrimitive.Attachments components={{ Attachment: AttachmentItem }} /> <ComposerPrimitive.Attachments components={{ Attachment: AttachmentItem }} />
</div> </div>
) : null} ) : null}
<div className="flex items-center px-4 py-3"> <div className={`flex px-4 py-3 ${
isExpanded ? 'min-h-0 flex-1 items-stretch' : 'items-center'
}`}>
{richMentionsEnabled ? ( {richMentionsEnabled ? (
<RichComposerInput <RichComposerInput
ref={richInputRef} ref={richInputRef}
@@ -1615,8 +1690,26 @@ export function HappyComposer(props: {
onPaste={handlePaste} onPaste={handlePaste}
resolveSessionMentionTooltip={resolveSessionMentionTooltip} resolveSessionMentionTooltip={resolveSessionMentionTooltip}
onEdit={handleRichEdit} onEdit={handleRichEdit}
className="max-h-[7.5rem] min-h-[1.5rem] flex-1 overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-base leading-snug text-[var(--app-fg)] focus:outline-none" className={editorClassName}
/> />
) : isExpanded ? (
<ComposerPrimitive.Input
asChild
ref={textareaRef}
autoFocus={!controlsDisabled && !isTouch}
submitOnEnter={false}
cancelOnEscape={false}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
>
<textarea
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
disabled={controlsDisabled}
className="h-full min-h-0 flex-1 resize-none overflow-y-auto bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
</ComposerPrimitive.Input>
) : ( ) : (
<ComposerPrimitive.Input <ComposerPrimitive.Input
ref={textareaRef} ref={textareaRef}
@@ -1640,6 +1733,8 @@ export function HappyComposer(props: {
controlsDisabled={controlsDisabled} controlsDisabled={controlsDisabled}
showSettingsButton={showSettingsButton} showSettingsButton={showSettingsButton}
onSettingsToggle={handleSettingsToggle} onSettingsToggle={handleSettingsToggle}
expanded={isExpanded}
onExpandedToggle={handleExpandedToggle}
showTerminalButton={showTerminalButton} showTerminalButton={showTerminalButton}
terminalDisabled={terminalDisabled} terminalDisabled={terminalDisabled}
terminalLabel={terminalLabel} terminalLabel={terminalLabel}
@@ -15,6 +15,7 @@ import { SettingsChoiceGroup } from './SettingsPrimitives'
const ITEM_LABEL_KEYS: Record<ComposerToolbarItemId, string> = { const ITEM_LABEL_KEYS: Record<ComposerToolbarItemId, string> = {
attachment: 'settings.chat.composerToolbar.item.attachment', attachment: 'settings.chat.composerToolbar.item.attachment',
settings: 'settings.chat.composerToolbar.item.settings', settings: 'settings.chat.composerToolbar.item.settings',
expand: 'settings.chat.composerToolbar.item.expand',
piModel: 'settings.chat.composerToolbar.item.piModel', piModel: 'settings.chat.composerToolbar.item.piModel',
piThinking: 'settings.chat.composerToolbar.item.piThinking', piThinking: 'settings.chat.composerToolbar.item.piThinking',
terminal: 'settings.chat.composerToolbar.item.terminal', terminal: 'settings.chat.composerToolbar.item.terminal',
@@ -11,6 +11,7 @@ describe('DEFAULT_COMPOSER_TOOLBAR_LAYOUT', () => {
expect(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.left).toEqual([ expect(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.left).toEqual([
'attachment', 'attachment',
'settings', 'settings',
'expand',
'piModel', 'piModel',
'piThinking', 'piThinking',
'terminal', 'terminal',
@@ -80,9 +81,9 @@ describe('normalizeComposerToolbarLayout', () => {
'piModel', 'piModel',
'piThinking', 'piThinking',
'terminal', 'terminal',
'expand',
'abort', 'abort',
'switch', 'switch',
'voiceMic',
'attachment', 'attachment',
]) ])
expect(result.left).toHaveLength(layout.left.length) expect(result.left).toHaveLength(layout.left.length)
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
export const COMPOSER_TOOLBAR_ITEM_IDS = [ export const COMPOSER_TOOLBAR_ITEM_IDS = [
'attachment', 'attachment',
'settings', 'settings',
'expand',
'piModel', 'piModel',
'piThinking', 'piThinking',
'terminal', 'terminal',
+3
View File
@@ -517,6 +517,8 @@ export default {
'composer.abort': 'Abort', 'composer.abort': 'Abort',
'composer.switchRemote': 'Switch to remote mode', 'composer.switchRemote': 'Switch to remote mode',
'composer.attach': 'Attach file', 'composer.attach': 'Attach file',
'composer.expand': 'Expand message editor',
'composer.collapse': 'Collapse message editor',
'composer.dropToAttach': 'Drop to attach', 'composer.dropToAttach': 'Drop to attach',
'composer.send': 'Send', 'composer.send': 'Send',
'composer.stop': 'Stop', 'composer.stop': 'Stop',
@@ -750,6 +752,7 @@ export default {
'settings.chat.composerToolbar.moveLater': 'Move later', 'settings.chat.composerToolbar.moveLater': 'Move later',
'settings.chat.composerToolbar.item.attachment': 'Add attachment', 'settings.chat.composerToolbar.item.attachment': 'Add attachment',
'settings.chat.composerToolbar.item.settings': 'Session settings', 'settings.chat.composerToolbar.item.settings': 'Session settings',
'settings.chat.composerToolbar.item.expand': 'Expand message editor',
'settings.chat.composerToolbar.item.piModel': 'Pi model', 'settings.chat.composerToolbar.item.piModel': 'Pi model',
'settings.chat.composerToolbar.item.piThinking': 'Pi thinking level', 'settings.chat.composerToolbar.item.piThinking': 'Pi thinking level',
'settings.chat.composerToolbar.item.terminal': 'Terminal', 'settings.chat.composerToolbar.item.terminal': 'Terminal',
+3
View File
@@ -521,6 +521,8 @@ export default {
'composer.abort': '中止', 'composer.abort': '中止',
'composer.switchRemote': '切换到远程模式', 'composer.switchRemote': '切换到远程模式',
'composer.attach': '添加文件', 'composer.attach': '添加文件',
'composer.expand': '展开消息编辑器',
'composer.collapse': '收起消息编辑器',
'composer.dropToAttach': '松开以添加文件', 'composer.dropToAttach': '松开以添加文件',
'composer.send': '发送', 'composer.send': '发送',
'composer.stop': '停止', 'composer.stop': '停止',
@@ -754,6 +756,7 @@ export default {
'settings.chat.composerToolbar.moveLater': '向后移动', 'settings.chat.composerToolbar.moveLater': '向后移动',
'settings.chat.composerToolbar.item.attachment': '添加附件', 'settings.chat.composerToolbar.item.attachment': '添加附件',
'settings.chat.composerToolbar.item.settings': '会话设置', 'settings.chat.composerToolbar.item.settings': '会话设置',
'settings.chat.composerToolbar.item.expand': '展开消息编辑器',
'settings.chat.composerToolbar.item.piModel': 'Pi 模型', 'settings.chat.composerToolbar.item.piModel': 'Pi 模型',
'settings.chat.composerToolbar.item.piThinking': 'Pi 思考等级', 'settings.chat.composerToolbar.item.piThinking': 'Pi 思考等级',
'settings.chat.composerToolbar.item.terminal': '终端', 'settings.chat.composerToolbar.item.terminal': '终端',