mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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 (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<I18nProvider>{props.children}</I18nProvider>
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function renderInProviders(ui: ReactElement) {
|
||||
return render(<I18nProvider>{ui}</I18nProvider>)
|
||||
@@ -103,3 +127,83 @@ describe('DictationButton', () => {
|
||||
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),
|
||||
)
|
||||
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') {
|
||||
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])}</>
|
||||
}
|
||||
|
||||
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() {
|
||||
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 }) {
|
||||
if (props.spinning) {
|
||||
return (
|
||||
@@ -248,6 +333,7 @@ export function ComposerToolbarItemPreview(props: { item: ComposerToolbarItemId;
|
||||
switch (props.item) {
|
||||
case 'attachment': return <AttachmentIcon />
|
||||
case 'settings': return <SettingsIcon />
|
||||
case 'expand': return <ComposerExpandIcon expanded={false} />
|
||||
case 'terminal': return <TerminalIcon />
|
||||
case 'abort': return <AbortIcon spinning={false} />
|
||||
case 'switch': return <SwitchToRemoteIcon />
|
||||
@@ -515,6 +601,8 @@ export function ComposerButtons(props: {
|
||||
controlsDisabled: boolean
|
||||
showSettingsButton: boolean
|
||||
onSettingsToggle: () => void
|
||||
expanded: boolean
|
||||
onExpandedToggle: () => void
|
||||
showTerminalButton: boolean
|
||||
terminalDisabled: boolean
|
||||
terminalLabel: string
|
||||
@@ -567,15 +655,15 @@ export function ComposerButtons(props: {
|
||||
|
||||
const hasSchedule = props.pendingSchedule != null
|
||||
const hasAttachments = props.hasAttachments ?? false
|
||||
const toolbarAlignmentClass = layout.mode === 'center'
|
||||
? 'justify-center'
|
||||
: layout.mode === 'right'
|
||||
? 'justify-end'
|
||||
: 'justify-start'
|
||||
const toolbarJustifyContent = getComposerToolbarJustifyContent(layout.mode)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 pb-2">
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-1 ${toolbarAlignmentClass}`}>
|
||||
<div className="flex shrink-0 items-center gap-1 px-2 pb-2">
|
||||
<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}>
|
||||
<ToolbarItemSlot item="attachment">
|
||||
<ComposerPrimitive.AddAttachment
|
||||
@@ -603,6 +691,13 @@ export function ComposerButtons(props: {
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="expand">
|
||||
<ComposerExpandButton
|
||||
expanded={props.expanded}
|
||||
onToggle={props.onExpandedToggle}
|
||||
/>
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="piModel">
|
||||
{props.piModelLabel ? (
|
||||
<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 { useEffect, useState } from 'react'
|
||||
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[])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
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
|
||||
* same `text` still trigger a fresh restore (the dedupe key is the id, not
|
||||
@@ -382,6 +393,7 @@ export function HappyComposer(props: {
|
||||
text: '',
|
||||
selection: { start: 0, end: 0 }
|
||||
})
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showPiModelPanel, setShowPiModelPanel] = useState(false)
|
||||
const [showPiThinkingPanel, setShowPiThinkingPanel] = useState(false)
|
||||
@@ -608,6 +620,39 @@ export function HappyComposer(props: {
|
||||
}
|
||||
}, [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 suggestion = suggestions[index]
|
||||
if (!suggestion) return
|
||||
@@ -873,17 +918,21 @@ export function HappyComposer(props: {
|
||||
handleSuggestionSelect(indexToSelect)
|
||||
return
|
||||
}
|
||||
if (key === 'Escape') {
|
||||
e.preventDefault()
|
||||
clearSuggestions()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'Escape' && threadIsRunning) {
|
||||
e.preventDefault()
|
||||
handleAbort()
|
||||
return
|
||||
if (key === 'Escape') {
|
||||
const action = getComposerEscapeAction({
|
||||
hasSuggestions: suggestions.length > 0,
|
||||
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) {
|
||||
@@ -912,6 +961,8 @@ export function HappyComposer(props: {
|
||||
composerEnterBehavior,
|
||||
richMentionsEnabled,
|
||||
flushAndSend,
|
||||
isExpanded,
|
||||
handleExpandedToggle,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1124,6 +1175,10 @@ export function HappyComposer(props: {
|
||||
haptic('light')
|
||||
}, [controlsDisabled, haptic])
|
||||
|
||||
const overlayPositionClass = isExpanded
|
||||
? 'absolute z-10 bottom-12 mb-2'
|
||||
: 'absolute z-10 bottom-[100%] mb-2'
|
||||
|
||||
const overlays = useMemo(() => {
|
||||
// Pi flavor: separate floating panels for model and thinking level.
|
||||
// (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) {
|
||||
const currentPiModel = selectedPiModel ?? null
|
||||
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
|
||||
models={piModels}
|
||||
currentModel={currentPiModel ? { provider: currentPiModel.provider, modelId: currentPiModel.modelId } : null}
|
||||
@@ -1151,7 +1206,7 @@ export function HappyComposer(props: {
|
||||
// Thinking level panel
|
||||
if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) {
|
||||
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
|
||||
currentLevel={effort}
|
||||
reasoning={selectedPiModel?.reasoning}
|
||||
@@ -1170,7 +1225,7 @@ export function HappyComposer(props: {
|
||||
// Non-Pi flavors: original unified gear menu
|
||||
if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) {
|
||||
return (
|
||||
<div className="absolute bottom-[100%] mb-2 w-full">
|
||||
<div className={`${overlayPositionClass} w-full`}>
|
||||
<FloatingOverlay maxHeight={320}>
|
||||
{showCollaborationSettings ? (
|
||||
<div className="py-2">
|
||||
@@ -1482,7 +1537,7 @@ export function HappyComposer(props: {
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
return (
|
||||
<div className="absolute bottom-[100%] mb-2 w-full">
|
||||
<div className={`${overlayPositionClass} w-full`}>
|
||||
<FloatingOverlay>
|
||||
<Autocomplete
|
||||
suggestions={suggestions}
|
||||
@@ -1535,13 +1590,27 @@ export function HappyComposer(props: {
|
||||
handleEffortChange,
|
||||
handleServiceTierChange,
|
||||
handleSuggestionSelect,
|
||||
overlayPositionClass,
|
||||
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 (
|
||||
<div className={`px-3 ${bottomPaddingClass} pt-2 bg-[var(--app-bg)]`}>
|
||||
<div className="mx-auto w-full max-w-content">
|
||||
<ComposerPrimitive.Root className="relative" onSubmit={handleSubmit}>
|
||||
<div className={shellClassName} data-testid="composer-shell" data-expanded={isExpanded || undefined}>
|
||||
<div className={innerClassName}>
|
||||
<ComposerPrimitive.Root className={rootClassName} onSubmit={handleSubmit}>
|
||||
{overlays}
|
||||
|
||||
<StatusBar
|
||||
@@ -1592,16 +1661,22 @@ export function HappyComposer(props: {
|
||||
|
||||
<div
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
{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 }} />
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<RichComposerInput
|
||||
ref={richInputRef}
|
||||
@@ -1615,8 +1690,26 @@ export function HappyComposer(props: {
|
||||
onPaste={handlePaste}
|
||||
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
|
||||
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
|
||||
ref={textareaRef}
|
||||
@@ -1640,6 +1733,8 @@ export function HappyComposer(props: {
|
||||
controlsDisabled={controlsDisabled}
|
||||
showSettingsButton={showSettingsButton}
|
||||
onSettingsToggle={handleSettingsToggle}
|
||||
expanded={isExpanded}
|
||||
onExpandedToggle={handleExpandedToggle}
|
||||
showTerminalButton={showTerminalButton}
|
||||
terminalDisabled={terminalDisabled}
|
||||
terminalLabel={terminalLabel}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SettingsChoiceGroup } from './SettingsPrimitives'
|
||||
const ITEM_LABEL_KEYS: Record<ComposerToolbarItemId, string> = {
|
||||
attachment: 'settings.chat.composerToolbar.item.attachment',
|
||||
settings: 'settings.chat.composerToolbar.item.settings',
|
||||
expand: 'settings.chat.composerToolbar.item.expand',
|
||||
piModel: 'settings.chat.composerToolbar.item.piModel',
|
||||
piThinking: 'settings.chat.composerToolbar.item.piThinking',
|
||||
terminal: 'settings.chat.composerToolbar.item.terminal',
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('DEFAULT_COMPOSER_TOOLBAR_LAYOUT', () => {
|
||||
expect(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.left).toEqual([
|
||||
'attachment',
|
||||
'settings',
|
||||
'expand',
|
||||
'piModel',
|
||||
'piThinking',
|
||||
'terminal',
|
||||
@@ -80,9 +81,9 @@ describe('normalizeComposerToolbarLayout', () => {
|
||||
'piModel',
|
||||
'piThinking',
|
||||
'terminal',
|
||||
'expand',
|
||||
'abort',
|
||||
'switch',
|
||||
'voiceMic',
|
||||
'attachment',
|
||||
])
|
||||
expect(result.left).toHaveLength(layout.left.length)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
export const COMPOSER_TOOLBAR_ITEM_IDS = [
|
||||
'attachment',
|
||||
'settings',
|
||||
'expand',
|
||||
'piModel',
|
||||
'piThinking',
|
||||
'terminal',
|
||||
|
||||
@@ -517,6 +517,8 @@ export default {
|
||||
'composer.abort': 'Abort',
|
||||
'composer.switchRemote': 'Switch to remote mode',
|
||||
'composer.attach': 'Attach file',
|
||||
'composer.expand': 'Expand message editor',
|
||||
'composer.collapse': 'Collapse message editor',
|
||||
'composer.dropToAttach': 'Drop to attach',
|
||||
'composer.send': 'Send',
|
||||
'composer.stop': 'Stop',
|
||||
@@ -750,6 +752,7 @@ export default {
|
||||
'settings.chat.composerToolbar.moveLater': 'Move later',
|
||||
'settings.chat.composerToolbar.item.attachment': 'Add attachment',
|
||||
'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.piThinking': 'Pi thinking level',
|
||||
'settings.chat.composerToolbar.item.terminal': 'Terminal',
|
||||
|
||||
@@ -521,6 +521,8 @@ export default {
|
||||
'composer.abort': '中止',
|
||||
'composer.switchRemote': '切换到远程模式',
|
||||
'composer.attach': '添加文件',
|
||||
'composer.expand': '展开消息编辑器',
|
||||
'composer.collapse': '收起消息编辑器',
|
||||
'composer.dropToAttach': '松开以添加文件',
|
||||
'composer.send': '发送',
|
||||
'composer.stop': '停止',
|
||||
@@ -754,6 +756,7 @@ export default {
|
||||
'settings.chat.composerToolbar.moveLater': '向后移动',
|
||||
'settings.chat.composerToolbar.item.attachment': '添加附件',
|
||||
'settings.chat.composerToolbar.item.settings': '会话设置',
|
||||
'settings.chat.composerToolbar.item.expand': '展开消息编辑器',
|
||||
'settings.chat.composerToolbar.item.piModel': 'Pi 模型',
|
||||
'settings.chat.composerToolbar.item.piThinking': 'Pi 思考等级',
|
||||
'settings.chat.composerToolbar.item.terminal': '终端',
|
||||
|
||||
Reference in New Issue
Block a user