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:
Ananovo
2026-07-24 10:58:46 +08:00
committed by GitHub
parent 33015b67db
commit 224ae07438
7 changed files with 570 additions and 12 deletions
@@ -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)
})
})
+157
View File
@@ -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 }
}
+28
View File
@@ -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.',
+28
View File
@@ -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': '选择当前后端使用的声音。',
+2
View File
@@ -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