ChatHeader.tsx
Language TypeScript7.3 KB216 lines
import { useState } from 'react'import { priceLabel, shortModel, usd } from '../lib/format'import { useCurrentModel, useStore } from '../state/store'import { ModelPicker } from './ModelPicker'export function ChatHeader() {const view = useStore((s) => s.view)const current = useCurrentModel()const presets = useStore((s) => s.presets)const applyPreset = useStore((s) => s.applyPreset)const pending = useStore((s) => s.pending)const client = useStore((s) => s.client)const refreshConversations = useStore((s) => s.refreshConversations)const open = useStore((s) => s.open)const newChat = useStore((s) => s.newChat)const toast = useStore((s) => s.toast)const [picker, setPicker] = useState(false)const [menu, setMenu] = useState<null | 'preset' | 'more'>(null)const [renaming, setRenaming] = useState(false)const [title, setTitle] = useState('')const conv = view?.conversationconst total = view?.messages.reduce((s, m) => s + m.cost_nanousd, 0) ?? 0const estimated = view?.messages.some((m) => m.usage_source === 'estimated')const presetId = conv ? conv.preset_id : pending.presetIdconst preset = presets.find((p) => p.id === presetId)const price = current.info?.pricesconst act = async (f: () => Promise<unknown>, done?: string) => {setMenu(null)try {await f()if (done) toast(done)await refreshConversations()} catch (err) {toast(String(err), 'error')}}return (<header className="chat-header" data-tauri-drag-region><div className="title-wrap" data-tauri-drag-region>{renaming && conv ? (<inputclassName="title-input"autoFocusvalue={title}onChange={(e) => setTitle(e.target.value)}onBlur={() => setRenaming(false)}onKeyDown={async (e) => {if (e.key === 'Enter') {await client?.renameConversation(conv.id, title)setRenaming(false)await open(conv.id)await refreshConversations()}if (e.key === 'Escape') setRenaming(false)}}aria-label="Chat title"/>) : (<h1className="chat-title"onDoubleClick={() => {if (conv) {setTitle(conv.title)setRenaming(true)}}}title={conv ? 'Double-click to rename' : undefined}>{conv?.title ?? 'New chat'}</h1>)}{conv && total > 0 && (<span className="chat-total" data-testid="chat-total" title="This conversation so far">{usd(total)}{estimated ? ' est.' : ''}</span>)}</div><div className="header-actions"><div className="anchor"><buttonclassName="model-button glass-tint"onClick={() => setPicker((v) => !v)}aria-haspopup="dialog"aria-expanded={picker}data-testid="model-button"><span className="model-button-name">{current.modelId ? shortModel(current.modelId) : 'Choose a model'}</span>{price && <span className="model-button-price">{priceLabel(price)}</span>}</button>{picker && <ModelPicker onClose={() => setPicker(false)} />}</div><div className="anchor"><buttonclassName="ghost"onClick={() => setMenu(menu === 'preset' ? null : 'preset')}aria-haspopup="menu"aria-expanded={menu === 'preset'}>{preset ? preset.name : 'Preset'}</button>{menu === 'preset' && (<div className="popover menu glass-strong" role="menu"><buttonrole="menuitem"className={!preset ? 'checked' : ''}onClick={() => {setMenu(null)void applyPreset(null)}}>No preset</button>{presets.map((p) => (<buttonrole="menuitem"key={p.id}className={p.id === presetId ? 'checked' : ''}title={p.system_prompt}onClick={() => {setMenu(null)void applyPreset(p)}}>{p.name}</button>))}<hr /><buttonrole="menuitem"onClick={() => {setMenu(null)useStore.getState().setPanel('settings')}}>Edit presets...</button></div>)}</div>{conv && (<div className="anchor"><buttonclassName="ghost icon"onClick={() => setMenu(menu === 'more' ? null : 'more')}aria-label="More actions"aria-haspopup="menu"aria-expanded={menu === 'more'}><svg viewBox="0 0 20 20" width="16" height="16" aria-hidden><circle cx="4.5" cy="10" r="1.6" fill="currentColor" /><circle cx="10" cy="10" r="1.6" fill="currentColor" /><circle cx="15.5" cy="10" r="1.6" fill="currentColor" /></svg></button>{menu === 'more' && client && (<div className="popover menu right glass-strong" role="menu"><buttonrole="menuitem"onClick={() =>act(async () => {const r = await client.exportConversation(conv.id)toast(`Exported to ${r.path}`)})}>Export to vault</button><buttonrole="menuitem"onClick={() => act(() => client.setPinned(conv.id, !conv.pinned).then(() => open(conv.id)))}>{conv.pinned ? 'Unpin' : 'Pin'}</button><buttonrole="menuitem"onClick={() => {setTitle(conv.title)setRenaming(true)setMenu(null)}}>Rename</button><buttonrole="menuitem"onClick={() => act(() => client.setArchived(conv.id, true).then(newChat), 'Archived.')}>Archive</button><hr /><buttonrole="menuitem"className="danger"onClick={() => {if (window.confirm(`Delete "${conv.title}"? This cannot be undone.`))void act(() => client.deleteConversation(conv.id).then(newChat), 'Deleted.')else setMenu(null)}}>Delete</button></div>)}</div>)}</div></header>)}