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 {
|
||||||
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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '终端',
|
||||||
|
|||||||
Reference in New Issue
Block a user