diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 0bb8484b..ee1be5f0 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -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)}{renderItems(props.layout.right)}> + } + return <>{renderItems([...props.layout.left, ...props.layout.right])}> +} function ChevronIcon() { return @@ -223,6 +241,37 @@ function ScratchlistToggleIcon() { ) } +export function ComposerToolbarItemPreview(props: { item: ComposerToolbarItemId; label: string }) { + const icon = (() => { + switch (props.item) { + case 'attachment': return + case 'settings': return + case 'terminal': return + case 'abort': return + case 'switch': return + case 'voiceMic': return + case 'scratchlist': return + case 'schedule': return + case 'piModel': + case 'piThinking': + return <>{props.label}> + } + })() + const isTextControl = props.item === 'piModel' || props.item === 'piThinking' + return ( + + {icon} + + ) +} + +export function ComposerSendButtonPreview() { + return +} + /** * 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} @@ -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(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 ( - - + + + + + + {props.showSettingsButton ? ( ) : null} + + {props.piModelLabel ? ( ) : null} + + {props.piThinkingLabel ? ( ) : null} + + {props.showTerminalButton ? ( ) : null} + + {props.showAbortButton ? ( ) : null} + + {props.showSwitchButton ? ( ) : null} + + {isVoiceConnected && props.onVoiceMicToggle ? ( ) : null} + {/* * 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. */} + {props.onScratchlistToggle ? ( - + + + ) : null} + {/* Schedule button — only shown when onSchedule handler is provided */} + {props.onSchedule ? ( - <> + )} - > + ) : null} + + = { + 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(null) + const [selectedItem, setSelectedItem] = useState(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 ( + { + 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' : ''}`} + > + + + ) + } + + const renderGroup = (group: ComposerToolbarGroup, items: ComposerToolbarItemId[], alignment: string, grow: boolean) => ( + { + 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))} + + ) + + 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 ( + + + {t('settings.chat.composerToolbar.title')} + {t('settings.chat.composerToolbar.description')} + + ({ value, label: t(labelKey) }))} + onChange={setMode} + /> + + + + {t('settings.chat.composerToolbar.order')} + {t('settings.chat.composerToolbar.previewHint')} + + {t('settings.chat.composerToolbar.reset')} + + + + {t('misc.typeAMessage')} + + + {layout.mode === 'split' ? ( + + {renderGroup('left', layout.left, 'justify-start', false)} + + {renderGroup('right', layout.right, 'justify-end', false)} + + ) : renderGroup('left', singleItems, singleAlignment, true)} + + + + + {selectedItem && selectedIndex >= 0 ? ( + + {t(ITEM_LABEL_KEYS[selectedItem])} + + 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" + > + ← + + 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" + > + → + + {layout.mode === 'split' ? ( + 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')} + + ) : null} + + + ) : null} + + ) +} diff --git a/web/src/hooks/useComposerToolbarLayout.test.ts b/web/src/hooks/useComposerToolbarLayout.test.ts new file mode 100644 index 00000000..e01d8b05 --- /dev/null +++ b/web/src/hooks/useComposerToolbarLayout.test.ts @@ -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) + }) +}) diff --git a/web/src/hooks/useComposerToolbarLayout.ts b/web/src/hooks/useComposerToolbarLayout.ts new file mode 100644 index 00000000..c732aa70 --- /dev/null +++ b/web/src/hooks/useComposerToolbarLayout.ts @@ -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 + const seen = new Set() + 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(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 } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 44e0c959..2e5bd0f1 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index f5ff0996..1a3b664c 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '选择当前后端使用的声音。', diff --git a/web/src/routes/settings/chat.tsx b/web/src/routes/settings/chat.tsx index 6f44beea..7823880b 100644 --- a/web/src/routes/settings/chat.tsx +++ b/web/src/routes/settings/chat.tsx @@ -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} /> +
{t('settings.chat.composerToolbar.description')}
{t('settings.chat.composerToolbar.previewHint')}