mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): customize composer toolbar layout (#1101)
* feat(web): customize composer toolbar layout * fix(web): reorder toolbar across split boundary * fix(web): match toolbar focus and visual order * fix(web): support accessible toolbar reordering
This commit is contained in:
@@ -6,7 +6,25 @@ import { ScheduleTimePicker } from './ScheduleTimePicker'
|
||||
import type { PendingSchedule } from './ScheduleTimePicker'
|
||||
import { useFue } from '@/lib/use-fue'
|
||||
import { FueCallout, FueDot } from '@/components/Fue'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Children, isValidElement, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { useComposerToolbarLayout, type ComposerToolbarItemId, type ComposerToolbarLayout } from '@/hooks/useComposerToolbarLayout'
|
||||
|
||||
function ToolbarItemSlot(props: { item: ComposerToolbarItemId; children: ReactNode }) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
function OrderedToolbarItems(props: { layout: ComposerToolbarLayout; children: ReactNode }) {
|
||||
const slots = Children.toArray(props.children).filter(
|
||||
(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)
|
||||
|
||||
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, ...props.layout.right])}</>
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -223,6 +241,37 @@ function ScratchlistToggleIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposerToolbarItemPreview(props: { item: ComposerToolbarItemId; label: string }) {
|
||||
const icon = (() => {
|
||||
switch (props.item) {
|
||||
case 'attachment': return <AttachmentIcon />
|
||||
case 'settings': return <SettingsIcon />
|
||||
case 'terminal': return <TerminalIcon />
|
||||
case 'abort': return <AbortIcon spinning={false} />
|
||||
case 'switch': return <SwitchToRemoteIcon />
|
||||
case 'voiceMic': return <SpeakerIcon />
|
||||
case 'scratchlist': return <ScratchlistToggleIcon />
|
||||
case 'schedule': return <ScheduleIcon className="h-[18px] w-[18px]" />
|
||||
case 'piModel':
|
||||
case 'piThinking':
|
||||
return <><span className="max-w-24 truncate text-xs font-medium">{props.label}</span><ChevronIcon /></>
|
||||
}
|
||||
})()
|
||||
const isTextControl = props.item === 'piModel' || props.item === 'piThinking'
|
||||
return (
|
||||
<span
|
||||
className={`flex h-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 ${isTextControl ? 'gap-1 px-3' : 'w-8'}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposerSendButtonPreview() {
|
||||
return <span className="flex h-10 w-10 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black" aria-hidden="true"><SendIcon /></span>
|
||||
}
|
||||
|
||||
/**
|
||||
* ScratchlistToggleButton — composer affordance for toggling scratchlist mode,
|
||||
* wrapped in the generic FUE (First-User Experience) primitive so a new
|
||||
@@ -423,7 +472,7 @@ export function UnifiedButton(props: {
|
||||
disabled={isDisabled}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
className={`ml-1 flex h-8 w-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
@@ -479,16 +528,24 @@ export function ComposerButtons(props: {
|
||||
onScratchlistToggle?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { layout } = useComposerToolbarLayout()
|
||||
const isVoiceConnected = props.voiceStatus === 'connected'
|
||||
const [showSchedulePicker, setShowSchedulePicker] = useState(false)
|
||||
const scheduleButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const hasSchedule = props.pendingSchedule != null
|
||||
const hasAttachments = props.hasAttachments ?? false
|
||||
const toolbarAlignmentClass = layout.mode === 'center'
|
||||
? 'justify-center'
|
||||
: layout.mode === 'right'
|
||||
? 'justify-end'
|
||||
: 'justify-start'
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-2 pb-2">
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-1 ${toolbarAlignmentClass}`}>
|
||||
<OrderedToolbarItems layout={layout}>
|
||||
<ToolbarItemSlot item="attachment">
|
||||
<ComposerPrimitive.AddAttachment
|
||||
aria-label={t('composer.attach')}
|
||||
title={t('composer.attach')}
|
||||
@@ -497,7 +554,9 @@ export function ComposerButtons(props: {
|
||||
>
|
||||
<AttachmentIcon />
|
||||
</ComposerPrimitive.AddAttachment>
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="settings">
|
||||
{props.showSettingsButton ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -510,7 +569,9 @@ export function ComposerButtons(props: {
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="piModel">
|
||||
{props.piModelLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -528,7 +589,9 @@ export function ComposerButtons(props: {
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="piThinking">
|
||||
{props.piThinkingLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -546,7 +609,9 @@ export function ComposerButtons(props: {
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="terminal">
|
||||
{props.showTerminalButton ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -559,7 +624,9 @@ export function ComposerButtons(props: {
|
||||
<TerminalIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="abort">
|
||||
{props.showAbortButton ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -572,7 +639,9 @@ export function ComposerButtons(props: {
|
||||
<AbortIcon spinning={props.isAborting} />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="switch">
|
||||
{props.showSwitchButton ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -585,7 +654,9 @@ export function ComposerButtons(props: {
|
||||
<SwitchToRemoteIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
<ToolbarItemSlot item="voiceMic">
|
||||
{isVoiceConnected && props.onVoiceMicToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -601,6 +672,7 @@ export function ComposerButtons(props: {
|
||||
<SpeakerIcon muted={props.voiceMicMuted} />
|
||||
</button>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
{/*
|
||||
* Scratchlist toggle - prototype of the composer-controlled
|
||||
@@ -613,18 +685,23 @@ export function ComposerButtons(props: {
|
||||
* submission to addScratchlistEntry() instead of the chat.
|
||||
* Mode is sticky - operator clicks the icon again to exit.
|
||||
*/}
|
||||
<ToolbarItemSlot item="scratchlist">
|
||||
{props.onScratchlistToggle ? (
|
||||
<ScratchlistToggleButton
|
||||
scratchlistMode={props.scratchlistMode ?? false}
|
||||
scratchlistCount={props.scratchlistCount ?? 0}
|
||||
onScratchlistToggle={props.onScratchlistToggle}
|
||||
controlsDisabled={props.controlsDisabled}
|
||||
/>
|
||||
<div>
|
||||
<ScratchlistToggleButton
|
||||
scratchlistMode={props.scratchlistMode ?? false}
|
||||
scratchlistCount={props.scratchlistCount ?? 0}
|
||||
onScratchlistToggle={props.onScratchlistToggle}
|
||||
controlsDisabled={props.controlsDisabled}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
|
||||
{/* Schedule button — only shown when onSchedule handler is provided */}
|
||||
<ToolbarItemSlot item="schedule">
|
||||
{props.onSchedule ? (
|
||||
<>
|
||||
<div>
|
||||
<button
|
||||
ref={scheduleButtonRef}
|
||||
type="button"
|
||||
@@ -657,8 +734,10 @@ export function ComposerButtons(props: {
|
||||
pendingSchedule={props.pendingSchedule}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</ToolbarItemSlot>
|
||||
</OrderedToolbarItems>
|
||||
</div>
|
||||
|
||||
<UnifiedButton
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, type DragEvent } from 'react'
|
||||
import { ComposerSendButtonPreview, ComposerToolbarItemPreview } from '@/components/AssistantChat/ComposerButtons'
|
||||
import {
|
||||
COMPOSER_TOOLBAR_ITEM_IDS,
|
||||
moveComposerToolbarItem,
|
||||
moveComposerToolbarItemInSingleLayout,
|
||||
useComposerToolbarLayout,
|
||||
type ComposerToolbarGroup,
|
||||
type ComposerToolbarItemId,
|
||||
type ComposerToolbarLayoutMode,
|
||||
} from '@/hooks/useComposerToolbarLayout'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { SettingsChoiceGroup } from './SettingsPrimitives'
|
||||
|
||||
const ITEM_LABEL_KEYS: Record<ComposerToolbarItemId, string> = {
|
||||
attachment: 'settings.chat.composerToolbar.item.attachment',
|
||||
settings: 'settings.chat.composerToolbar.item.settings',
|
||||
piModel: 'settings.chat.composerToolbar.item.piModel',
|
||||
piThinking: 'settings.chat.composerToolbar.item.piThinking',
|
||||
terminal: 'settings.chat.composerToolbar.item.terminal',
|
||||
abort: 'settings.chat.composerToolbar.item.abort',
|
||||
switch: 'settings.chat.composerToolbar.item.switch',
|
||||
voiceMic: 'settings.chat.composerToolbar.item.voiceMic',
|
||||
scratchlist: 'settings.chat.composerToolbar.item.scratchlist',
|
||||
schedule: 'settings.chat.composerToolbar.item.schedule',
|
||||
}
|
||||
|
||||
export function ComposerToolbarLayoutControl() {
|
||||
const { t } = useTranslation()
|
||||
const { layout, setLayout, resetLayout } = useComposerToolbarLayout()
|
||||
const [draggedItem, setDraggedItem] = useState<ComposerToolbarItemId | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<ComposerToolbarItemId | null>(null)
|
||||
|
||||
const setMode = (mode: ComposerToolbarLayoutMode) => {
|
||||
setLayout({ ...layout, mode })
|
||||
}
|
||||
|
||||
const moveDraggedItem = (group: ComposerToolbarGroup, index: number) => {
|
||||
if (draggedItem) {
|
||||
const next = layout.mode === 'split'
|
||||
? moveComposerToolbarItem(layout, draggedItem, group, index)
|
||||
: moveComposerToolbarItemInSingleLayout(layout, draggedItem, index)
|
||||
const unchanged = next.left.join() === layout.left.join() && next.right.join() === layout.right.join()
|
||||
if (!unchanged) {
|
||||
setLayout(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent, group: ComposerToolbarGroup, index: number) => {
|
||||
event.preventDefault()
|
||||
const item = draggedItem ?? event.dataTransfer.getData('text/plain')
|
||||
if ((COMPOSER_TOOLBAR_ITEM_IDS as readonly string[]).includes(item)) {
|
||||
const next = layout.mode === 'split'
|
||||
? moveComposerToolbarItem(layout, item as ComposerToolbarItemId, group, index)
|
||||
: moveComposerToolbarItemInSingleLayout(layout, item as ComposerToolbarItemId, index)
|
||||
setLayout(next)
|
||||
}
|
||||
setDraggedItem(null)
|
||||
}
|
||||
|
||||
const moveItemByOffset = (item: ComposerToolbarItemId, group: ComposerToolbarGroup, index: number, offset: -1 | 1) => {
|
||||
const targetIndex = Math.max(0, index + offset)
|
||||
const next = layout.mode === 'split'
|
||||
? moveComposerToolbarItem(layout, item, group, targetIndex)
|
||||
: moveComposerToolbarItemInSingleLayout(layout, item, targetIndex)
|
||||
setLayout(next)
|
||||
}
|
||||
|
||||
const renderItem = (item: ComposerToolbarItemId, group: ComposerToolbarGroup, index: number) => {
|
||||
const label = t(ITEM_LABEL_KEYS[item])
|
||||
return (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
draggable
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onDragStart={(event) => {
|
||||
setDraggedItem(item)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', item)
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault()
|
||||
moveDraggedItem(group, index)
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.stopPropagation()
|
||||
onDrop(event, group, index)
|
||||
}}
|
||||
onDragEnd={() => setDraggedItem(null)}
|
||||
onClick={() => setSelectedItem((current) => current === item ? null : item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowLeft' && index > 0) {
|
||||
event.preventDefault()
|
||||
moveItemByOffset(item, group, index, -1)
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
moveItemByOffset(item, group, index, 1)
|
||||
}
|
||||
}}
|
||||
aria-pressed={selectedItem === item}
|
||||
className={`cursor-grab rounded-full transition-colors hover:bg-[var(--app-bg)] active:cursor-grabbing ${selectedItem === item ? 'bg-[var(--app-bg)] ring-1 ring-[var(--app-link)]' : ''} ${draggedItem === item ? 'opacity-35' : ''}`}
|
||||
>
|
||||
<ComposerToolbarItemPreview item={item} label={label} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroup = (group: ComposerToolbarGroup, items: ComposerToolbarItemId[], alignment: string, grow: boolean) => (
|
||||
<div
|
||||
className={`flex min-h-10 min-w-12 items-center gap-1 rounded-lg ${grow ? 'flex-1' : 'shrink-0'} ${alignment}`}
|
||||
onDragEnter={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
moveDraggedItem(group, items.length)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => onDrop(event, group, items.length)}
|
||||
>
|
||||
{items.map((item, index) => renderItem(item, group, index))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const singleAlignment = layout.mode === 'center' ? 'justify-center' : layout.mode === 'right' ? 'justify-end' : 'justify-start'
|
||||
const singleItems = [...layout.left, ...layout.right]
|
||||
const selectedGroup: ComposerToolbarGroup = selectedItem && layout.right.includes(selectedItem) ? 'right' : 'left'
|
||||
const selectedItems = layout.mode === 'split' ? layout[selectedGroup] : singleItems
|
||||
const selectedIndex = selectedItem ? selectedItems.indexOf(selectedItem) : -1
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--app-divider)] px-3 py-3">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[var(--app-fg)]">{t('settings.chat.composerToolbar.title')}</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-hint)]">{t('settings.chat.composerToolbar.description')}</p>
|
||||
</div>
|
||||
<SettingsChoiceGroup
|
||||
label={t('settings.chat.composerToolbar.layout')}
|
||||
value={layout.mode}
|
||||
columns={4}
|
||||
options={([
|
||||
['left', 'settings.chat.composerToolbar.layout.left'],
|
||||
['center', 'settings.chat.composerToolbar.layout.center'],
|
||||
['right', 'settings.chat.composerToolbar.layout.right'],
|
||||
['split', 'settings.chat.composerToolbar.layout.split'],
|
||||
] as const).map(([value, labelKey]) => ({ value, label: t(labelKey) }))}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 px-3">
|
||||
<div>
|
||||
<h4 className="text-[var(--app-fg)]">{t('settings.chat.composerToolbar.order')}</h4>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-hint)]">{t('settings.chat.composerToolbar.previewHint')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={resetLayout} className="shrink-0 text-sm text-[var(--app-link)] hover:underline">{t('settings.chat.composerToolbar.reset')}</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-[24px] bg-[var(--app-composer-bg,var(--app-subtle-bg))] px-3 pb-2 pt-3 shadow-sm ring-1 ring-[var(--app-border)]">
|
||||
<div className="mb-2 px-1 text-sm text-[var(--app-hint)]">{t('misc.typeAMessage')}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
{layout.mode === 'split' ? (
|
||||
<div className="flex min-w-full items-center gap-1">
|
||||
{renderGroup('left', layout.left, 'justify-start', false)}
|
||||
<span className="min-w-2 flex-1" aria-hidden="true" />
|
||||
{renderGroup('right', layout.right, 'justify-end', false)}
|
||||
</div>
|
||||
) : renderGroup('left', singleItems, singleAlignment, true)}
|
||||
</div>
|
||||
<span className="ml-1" title={t('composer.send')}><ComposerSendButtonPreview /></span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedItem && selectedIndex >= 0 ? (
|
||||
<div className="mt-2 flex items-center justify-between gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate text-[var(--app-hint)]">{t(ITEM_LABEL_KEYS[selectedItem])}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedIndex === 0}
|
||||
aria-label={t('settings.chat.composerToolbar.moveEarlier')}
|
||||
title={t('settings.chat.composerToolbar.moveEarlier')}
|
||||
onClick={() => moveItemByOffset(selectedItem, selectedGroup, selectedIndex, -1)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full hover:bg-[var(--app-bg)] disabled:opacity-35"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedIndex === selectedItems.length - 1}
|
||||
aria-label={t('settings.chat.composerToolbar.moveLater')}
|
||||
title={t('settings.chat.composerToolbar.moveLater')}
|
||||
onClick={() => moveItemByOffset(selectedItem, selectedGroup, selectedIndex, 1)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full hover:bg-[var(--app-bg)] disabled:opacity-35"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
{layout.mode === 'split' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={selectedGroup === 'left' ? t('settings.chat.composerToolbar.moveRight') : t('settings.chat.composerToolbar.moveLeft')}
|
||||
title={selectedGroup === 'left' ? t('settings.chat.composerToolbar.moveRight') : t('settings.chat.composerToolbar.moveLeft')}
|
||||
onClick={() => setLayout(moveComposerToolbarItem(layout, selectedItem, selectedGroup === 'left' ? 'right' : 'left', selectedGroup === 'left' ? layout.right.length : layout.left.length))}
|
||||
className="ml-1 rounded-lg px-2.5 py-1.5 text-xs text-[var(--app-link)] hover:bg-[var(--app-bg)]"
|
||||
>
|
||||
{selectedGroup === 'left' ? t('settings.chat.composerToolbar.rightGroup') : t('settings.chat.composerToolbar.leftGroup')}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_COMPOSER_TOOLBAR_LAYOUT,
|
||||
moveComposerToolbarItemInSingleLayout,
|
||||
normalizeComposerToolbarLayout,
|
||||
} from './useComposerToolbarLayout'
|
||||
|
||||
describe('normalizeComposerToolbarLayout', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('falls back to the default layout for invalid data', () => {
|
||||
expect(normalizeComposerToolbarLayout(null)).toEqual(DEFAULT_COMPOSER_TOOLBAR_LAYOUT)
|
||||
})
|
||||
|
||||
it('keeps valid order, removes duplicates, and appends newly introduced items', () => {
|
||||
const result = normalizeComposerToolbarLayout({
|
||||
mode: 'split',
|
||||
left: ['settings', 'attachment', 'settings', 'unknown'],
|
||||
right: ['abort', 'schedule', 'attachment'],
|
||||
})
|
||||
|
||||
expect(result.mode).toBe('split')
|
||||
expect(result.left.slice(0, 2)).toEqual(['settings', 'attachment'])
|
||||
expect(result.right).toEqual(['abort', 'schedule'])
|
||||
expect([...result.left, ...result.right]).toHaveLength(DEFAULT_COMPOSER_TOOLBAR_LAYOUT.left.length)
|
||||
})
|
||||
|
||||
it('reorders across a hidden split boundary in single-column modes', () => {
|
||||
const layout = normalizeComposerToolbarLayout({
|
||||
mode: 'right',
|
||||
left: ['attachment', 'settings', 'piModel', 'piThinking', 'terminal'],
|
||||
right: ['abort', 'switch', 'voiceMic', 'scratchlist', 'schedule'],
|
||||
})
|
||||
const result = moveComposerToolbarItemInSingleLayout(layout, 'attachment', 7)
|
||||
|
||||
expect([...result.left, ...result.right].slice(0, 8)).toEqual([
|
||||
'settings',
|
||||
'piModel',
|
||||
'piThinking',
|
||||
'terminal',
|
||||
'abort',
|
||||
'switch',
|
||||
'voiceMic',
|
||||
'attachment',
|
||||
])
|
||||
expect(result.left).toHaveLength(layout.left.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export const COMPOSER_TOOLBAR_ITEM_IDS = [
|
||||
'attachment',
|
||||
'settings',
|
||||
'piModel',
|
||||
'piThinking',
|
||||
'terminal',
|
||||
'abort',
|
||||
'switch',
|
||||
'voiceMic',
|
||||
'scratchlist',
|
||||
'schedule',
|
||||
] as const
|
||||
|
||||
export type ComposerToolbarItemId = typeof COMPOSER_TOOLBAR_ITEM_IDS[number]
|
||||
export type ComposerToolbarLayoutMode = 'left' | 'center' | 'right' | 'split'
|
||||
export type ComposerToolbarGroup = 'left' | 'right'
|
||||
|
||||
export type ComposerToolbarLayout = {
|
||||
mode: ComposerToolbarLayoutMode
|
||||
left: ComposerToolbarItemId[]
|
||||
right: ComposerToolbarItemId[]
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPOSER_TOOLBAR_LAYOUT: ComposerToolbarLayout = {
|
||||
mode: 'left',
|
||||
left: [...COMPOSER_TOOLBAR_ITEM_IDS],
|
||||
right: [],
|
||||
}
|
||||
|
||||
export function moveComposerToolbarItem(
|
||||
layout: ComposerToolbarLayout,
|
||||
item: ComposerToolbarItemId,
|
||||
targetGroup: ComposerToolbarGroup,
|
||||
targetIndex: number,
|
||||
): ComposerToolbarLayout {
|
||||
const left = layout.left.filter((entry) => entry !== item)
|
||||
const right = layout.right.filter((entry) => entry !== item)
|
||||
const target = targetGroup === 'left' ? left : right
|
||||
target.splice(Math.max(0, Math.min(targetIndex, target.length)), 0, item)
|
||||
return { ...layout, left, right }
|
||||
}
|
||||
|
||||
export function moveComposerToolbarItemInSingleLayout(
|
||||
layout: ComposerToolbarLayout,
|
||||
item: ComposerToolbarItemId,
|
||||
targetIndex: number,
|
||||
): ComposerToolbarLayout {
|
||||
const leftCount = layout.left.length
|
||||
const items = [...layout.left, ...layout.right].filter((entry) => entry !== item)
|
||||
items.splice(Math.max(0, Math.min(targetIndex, items.length)), 0, item)
|
||||
return {
|
||||
...layout,
|
||||
left: items.slice(0, leftCount),
|
||||
right: items.slice(leftCount),
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hapi-composer-toolbar-layout'
|
||||
const CHANGE_EVENT = 'hapi-composer-toolbar-layout-change'
|
||||
|
||||
function isItemId(value: unknown): value is ComposerToolbarItemId {
|
||||
return typeof value === 'string' && (COMPOSER_TOOLBAR_ITEM_IDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
function isLayoutMode(value: unknown): value is ComposerToolbarLayoutMode {
|
||||
return value === 'left' || value === 'center' || value === 'right' || value === 'split'
|
||||
}
|
||||
|
||||
export function normalizeComposerToolbarLayout(value: unknown): ComposerToolbarLayout {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return DEFAULT_COMPOSER_TOOLBAR_LAYOUT
|
||||
}
|
||||
|
||||
const candidate = value as Partial<ComposerToolbarLayout>
|
||||
const seen = new Set<ComposerToolbarItemId>()
|
||||
const normalizeGroup = (group: unknown): ComposerToolbarItemId[] => {
|
||||
if (!Array.isArray(group)) {
|
||||
return []
|
||||
}
|
||||
return group.filter((item): item is ComposerToolbarItemId => {
|
||||
if (!isItemId(item) || seen.has(item)) {
|
||||
return false
|
||||
}
|
||||
seen.add(item)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const left = normalizeGroup(candidate.left)
|
||||
const right = normalizeGroup(candidate.right)
|
||||
for (const item of COMPOSER_TOOLBAR_ITEM_IDS) {
|
||||
if (!seen.has(item)) {
|
||||
left.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: isLayoutMode(candidate.mode) ? candidate.mode : DEFAULT_COMPOSER_TOOLBAR_LAYOUT.mode,
|
||||
left,
|
||||
right,
|
||||
}
|
||||
}
|
||||
|
||||
function readLayout(): ComposerToolbarLayout {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_COMPOSER_TOOLBAR_LAYOUT
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? normalizeComposerToolbarLayout(JSON.parse(raw)) : DEFAULT_COMPOSER_TOOLBAR_LAYOUT
|
||||
} catch {
|
||||
return DEFAULT_COMPOSER_TOOLBAR_LAYOUT
|
||||
}
|
||||
}
|
||||
|
||||
export function useComposerToolbarLayout(): {
|
||||
layout: ComposerToolbarLayout
|
||||
setLayout: (layout: ComposerToolbarLayout) => void
|
||||
resetLayout: () => void
|
||||
} {
|
||||
const [layout, setLayoutState] = useState<ComposerToolbarLayout>(readLayout)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setLayoutState(readLayout())
|
||||
window.addEventListener('storage', sync)
|
||||
window.addEventListener(CHANGE_EVENT, sync)
|
||||
return () => {
|
||||
window.removeEventListener('storage', sync)
|
||||
window.removeEventListener(CHANGE_EVENT, sync)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setLayout = useCallback((next: ComposerToolbarLayout) => {
|
||||
const normalized = normalizeComposerToolbarLayout(next)
|
||||
setLayoutState(normalized)
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized))
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT))
|
||||
} catch {
|
||||
// Keep the in-memory preference when storage is unavailable.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetLayout = useCallback(() => {
|
||||
setLayoutState(DEFAULT_COMPOSER_TOOLBAR_LAYOUT)
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT))
|
||||
} catch {
|
||||
// Keep the in-memory default when storage is unavailable.
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { layout, setLayout, resetLayout }
|
||||
}
|
||||
@@ -667,6 +667,34 @@ export default {
|
||||
'settings.chat.surfaceColor.softGreen': 'Soft green',
|
||||
'settings.chat.surfaceColor.softYellow': 'Soft yellow',
|
||||
'settings.chat.surfaceColor.custom': 'Custom color',
|
||||
'settings.chat.composerToolbar.title': 'Customize composer toolbar layout',
|
||||
'settings.chat.composerToolbar.description': 'Choose how toolbar actions are arranged below the message input.',
|
||||
'settings.chat.composerToolbar.layout': 'Alignment',
|
||||
'settings.chat.composerToolbar.order': 'Tool order',
|
||||
'settings.chat.composerToolbar.layout.left': 'Left',
|
||||
'settings.chat.composerToolbar.layout.center': 'Center',
|
||||
'settings.chat.composerToolbar.layout.right': 'Right',
|
||||
'settings.chat.composerToolbar.layout.split': 'Split',
|
||||
'settings.chat.composerToolbar.dragHint': 'Drag actions to reorder them. In split layout, drag between the two groups.',
|
||||
'settings.chat.composerToolbar.previewHint': 'Drag the actual toolbar icons below. Changes are reflected in this preview immediately; hover an icon to see its action.',
|
||||
'settings.chat.composerToolbar.reset': 'Reset',
|
||||
'settings.chat.composerToolbar.leftGroup': 'Left group',
|
||||
'settings.chat.composerToolbar.rightGroup': 'Right group',
|
||||
'settings.chat.composerToolbar.emptyGroup': 'Drop actions here',
|
||||
'settings.chat.composerToolbar.moveLeft': 'Move to left group',
|
||||
'settings.chat.composerToolbar.moveRight': 'Move to right group',
|
||||
'settings.chat.composerToolbar.moveEarlier': 'Move earlier',
|
||||
'settings.chat.composerToolbar.moveLater': 'Move later',
|
||||
'settings.chat.composerToolbar.item.attachment': 'Add attachment',
|
||||
'settings.chat.composerToolbar.item.settings': 'Session settings',
|
||||
'settings.chat.composerToolbar.item.piModel': 'Pi model',
|
||||
'settings.chat.composerToolbar.item.piThinking': 'Pi thinking level',
|
||||
'settings.chat.composerToolbar.item.terminal': 'Terminal',
|
||||
'settings.chat.composerToolbar.item.abort': 'Abort',
|
||||
'settings.chat.composerToolbar.item.switch': 'Switch to remote',
|
||||
'settings.chat.composerToolbar.item.voiceMic': 'Voice microphone',
|
||||
'settings.chat.composerToolbar.item.scratchlist': 'Scratchlist',
|
||||
'settings.chat.composerToolbar.item.schedule': 'Schedule send',
|
||||
'settings.voice.title': 'Voice Assistant',
|
||||
'settings.voice.description': 'Voice connection, language, and everyday behavior.',
|
||||
'settings.voice.voices.description': 'Choose the voice used by the selected backend.',
|
||||
|
||||
@@ -671,6 +671,34 @@ export default {
|
||||
'settings.chat.surfaceColor.softGreen': '柔和绿',
|
||||
'settings.chat.surfaceColor.softYellow': '柔和黄',
|
||||
'settings.chat.surfaceColor.custom': '自定义颜色',
|
||||
'settings.chat.composerToolbar.title': '输入栏工具自定义布局',
|
||||
'settings.chat.composerToolbar.description': '设置消息输入框底部工具的排列方式。',
|
||||
'settings.chat.composerToolbar.layout': '对齐方式',
|
||||
'settings.chat.composerToolbar.order': '工具顺序',
|
||||
'settings.chat.composerToolbar.layout.left': '左对齐',
|
||||
'settings.chat.composerToolbar.layout.center': '居中',
|
||||
'settings.chat.composerToolbar.layout.right': '右对齐',
|
||||
'settings.chat.composerToolbar.layout.split': '左右分组',
|
||||
'settings.chat.composerToolbar.dragHint': '拖动操作项可调整顺序;左右分组布局下也可在两组之间拖动。',
|
||||
'settings.chat.composerToolbar.previewHint': '直接拖动下方的实际工具图标,预览会立即更新;鼠标悬停可查看功能名称。',
|
||||
'settings.chat.composerToolbar.reset': '恢复默认',
|
||||
'settings.chat.composerToolbar.leftGroup': '左侧分组',
|
||||
'settings.chat.composerToolbar.rightGroup': '右侧分组',
|
||||
'settings.chat.composerToolbar.emptyGroup': '将操作拖到这里',
|
||||
'settings.chat.composerToolbar.moveLeft': '移至左侧分组',
|
||||
'settings.chat.composerToolbar.moveRight': '移至右侧分组',
|
||||
'settings.chat.composerToolbar.moveEarlier': '向前移动',
|
||||
'settings.chat.composerToolbar.moveLater': '向后移动',
|
||||
'settings.chat.composerToolbar.item.attachment': '添加附件',
|
||||
'settings.chat.composerToolbar.item.settings': '会话设置',
|
||||
'settings.chat.composerToolbar.item.piModel': 'Pi 模型',
|
||||
'settings.chat.composerToolbar.item.piThinking': 'Pi 思考等级',
|
||||
'settings.chat.composerToolbar.item.terminal': '终端',
|
||||
'settings.chat.composerToolbar.item.abort': '中止',
|
||||
'settings.chat.composerToolbar.item.switch': '切换到远程模式',
|
||||
'settings.chat.composerToolbar.item.voiceMic': '语音麦克风',
|
||||
'settings.chat.composerToolbar.item.scratchlist': '暂存清单',
|
||||
'settings.chat.composerToolbar.item.schedule': '定时发送',
|
||||
'settings.voice.title': '语音助手',
|
||||
'settings.voice.description': '语音连接、语言和常用行为。',
|
||||
'settings.voice.voices.description': '选择当前后端使用的声音。',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ChatSurfaceColorPreset,
|
||||
} from '@/hooks/useChatSurfaceColors'
|
||||
import { SettingsChoiceGroup, SettingsPageContent, SettingsSection } from '@/components/settings/SettingsPrimitives'
|
||||
import { ComposerToolbarLayoutControl } from '@/components/settings/ComposerToolbarLayoutControl'
|
||||
|
||||
function ChatSurfaceColorControl(props: {
|
||||
label: string
|
||||
@@ -57,6 +58,7 @@ export default function SettingsChatPage() {
|
||||
options={getComposerEnterBehaviorOptions().map((option) => ({ value: option.value, label: t(option.labelKey) }))}
|
||||
onChange={setComposerEnterBehavior}
|
||||
/>
|
||||
<ComposerToolbarLayoutControl />
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t('settings.chat.tools')}>
|
||||
<SettingsChoiceGroup
|
||||
|
||||
Reference in New Issue
Block a user