QuickApp.tsx
Language TypeScript4.7 KB135 lines
import { useEffect, useLayoutEffect, useRef, useState } from 'react'import { Markdown } from './components/Markdown'import { CostLine } from './components/MessageBubble'import type { IpcClient } from './ipc/client'import type { Message } from './ipc/gen/Message'import type { Settings } from './ipc/gen/Settings'import type { StreamEvent } from './ipc/gen/StreamEvent'import { ipc } from './ipc'import { shortModel } from './lib/format'import { mod, QUICK_HEIGHT, QUICK_MAX, useTheme } from './lib/theme'import { useStore } from './state/store'/** The small always-on-top prompt: one question, one answer, hand off with Cmd+Enter. */export function QuickApp() {const [client, setClient] = useState<IpcClient | null>(null)const [settings, setSettings] = useState<Settings | null>(null)const [vibrancy, setVibrancy] = useState(false)const [text, setText] = useState('')const [answer, setAnswer] = useState('')const [done, setDone] = useState<Message | null>(null)const [error, setError] = useState<string | null>(null)const [conversationId, setConversationId] = useState<string | null>(null)const [streamId, setStreamId] = useState<string | null>(null)// Enter before the settings have loaded: remember it and send once they have.const [queued, setQueued] = useState(false)const input = useRef<HTMLInputElement>(null)const body = useRef<HTMLDivElement>(null)useTheme(settings, vibrancy)useEffect(() => {void ipc().then(async (c) => {useStore.setState({ client: c })setClient(c)setSettings(await c.getSettings())setVibrancy((await c.appInfo()).vibrancy)c.on('settings-changed', (s) => setSettings(s as Settings))c.on('quick-shown', () => {setText('')setAnswer('')setDone(null)setError(null)setConversationId(null)void c.resizeQuick(QUICK_HEIGHT)input.current?.focus()})input.current?.focus()})}, [])// Grow the window to fit the answer.useLayoutEffect(() => {if (!client) returnconst h =answer || error ? Math.min(QUICK_MAX, QUICK_HEIGHT + (body.current?.scrollHeight ?? 0) + 44) : QUICK_HEIGHTvoid client.resizeQuick(h)}, [answer, error, done, client])const ask = async () => {if (!text.trim() || streamId) returnif (!client || !settings) {setQueued(true)return}setQueued(false)setAnswer('')setDone(null)setError(null)try {const conv = await client.createConversation(settings.quick_provider_id, settings.quick_model_id, null, 'quick')setConversationId(conv.id)const ack = await client.sendMessage({ conversation_id: conv.id, text, attachment_ids: [] }, (e: StreamEvent) => {if (e.type === 'delta') setAnswer((a) => a + e.text)if (e.type === 'done') {setDone(e.message)setStreamId(null)}if (e.type === 'error') {setDone(e.message)setError(e.error)setStreamId(null)}})setStreamId(ack.stream_id)} catch (err) {setError(String(err))}}useEffect(() => {if (queued && client && settings) void ask()}, [queued, client, settings])return (<div className="quick glass" data-testid="quick"><div className="quick-row" data-tauri-drag-region><span className="quick-mark" aria-hidden /><inputref={input}className="quick-input"placeholder="Ask quickly"aria-label="Quick prompt"value={text}onChange={(e) => setText(e.target.value)}onKeyDown={(e) => {if (e.key === 'Escape') {if (streamId) void client?.cancelStream(streamId)else void client?.hideQuick()} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {e.preventDefault()if (conversationId) void client?.quickOpenInMain(conversationId)} else if (e.key === 'Enter') {e.preventDefault()void ask()}}}/><span className="quick-model" title={settings?.quick_model_id}>{settings ? shortModel(settings.quick_model_id) : ''}</span></div>{(answer || error) && (<div className="quick-body" ref={body}>{answer && <Markdown text={answer} />}{error && <div className="msg-error">{error}</div>}<div className="quick-foot">{done && <CostLine m={done} />}<span className="spacer" />{conversationId && <span className="muted small">{mod}Enter opens in Cortex</span>}</div></div>)}</div>)}