Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions scripts/ui-flows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ let failed = 0
const check = (name, ok, detail = '') => { if (!ok) failed++; console.log((ok ? 'PASS ' : 'FAIL ') + name + (!ok && detail ? ` [${detail}]` : '')) }
const main = () => p.locator('.main').innerText()
const bot = name => p.locator('.rail-item', { hasText: name }).filter({ hasNot: p.locator('text=Built-in chat') }).first()
const tab = name => p.locator('.toolbar').getByText(name, { exact: true }).first()
// The chat's sections open as sheets over it from the icons at its top right (or, with one open, its tabs); back to the
// chat is the sheet's ×.
const tab = name => name === 'Chat' ? p.locator('.sheet-close')
: p.locator(`.sheet [role=tab]:text-is("${name}"), .main:not(:has(.sheet)) .chat-tool[aria-label="${name}"]`)
const say = async text => { const box = p.locator('textarea').first(); await box.fill(text); await box.press('Enter') }
const until = async (fn, ms = 15000) => { const t = Date.now(); while (Date.now() - t < ms) { if (await fn()) return true; await p.waitForTimeout(300) } return false }

Expand Down Expand Up @@ -151,14 +154,14 @@ await bot('Ada').click(); await p.waitForTimeout(500)

// Bot settings: Ada's page, then Search's: Search's own name and role
await settings('Ada')
const nameOf = () => p.locator('.main input').first().inputValue()
const nameOf = () => p.locator('.sheet input').first().inputValue()
check("Ada's page shows Ada", (await nameOf()) === 'Ada', await nameOf())
await settings('Search')
check("Search's page shows Search", (await nameOf()) === 'Search', await nameOf())
check("Search's page shows its role", (await main()).includes('SEO research') || (await p.locator('.main textarea').first().inputValue()).includes('SEO research'))
check("Search's page shows its role", (await main()).includes('SEO research') || (await p.locator('.sheet textarea').first().inputValue()).includes('SEO research'))
// Rename from the page
await p.locator('.main input').first().fill('Searcher')
const save = p.locator('.main').getByRole('button', { name: /^Save/ }).first()
await p.locator('.sheet input').first().fill('Searcher')
const save = p.locator('.sheet').getByRole('button', { name: /^Save/ }).first()
check('bot page has a Save button', (await save.count()) > 0)
if (await save.count()) { await save.click(); await p.waitForTimeout(1200) }
check('rename shows in the sidebar', await until(async () => (await p.locator('.rail-item', { hasText: 'Searcher' }).count()) > 0))
Expand All @@ -185,7 +188,7 @@ execFileSync('sqlite3', [db, `${seed} INSERT INTO tool_calls(run_id, chat_id, to
${seed} INSERT INTO messages(chat_id, bot_id, role, content, ts, run_id)
SELECT c.chat_id, c.bot_id, CASE i % 2 WHEN 1 THEN 'user' ELSE 'assistant' END, 'seed ' || i, strftime('%s', 'now'), CASE i % 2 WHEN 0 THEN 900000 + i END FROM n, c;`])
const before = errs.length
await bot('Ada').click(); await tab('Chat').click(); await p.waitForTimeout(1000)
await bot('Ada').click(); await p.waitForTimeout(1000)
const shown = () => p.locator('.main').innerText({ timeout: 2000 }).catch(() => '')
for (const k of [1, 2, 3]) { await say(`long chat ${k}`).catch(() => {}); await until(async () => (await shown()).includes(`OK: long chat ${k}`), 8000); await p.waitForTimeout(4500) }
check('a 250-message chat takes 3 more exchanges', (await shown()).includes('OK: long chat 3') && errs.length === before, errs.slice(before).join(' | '))
Expand Down
328 changes: 181 additions & 147 deletions ui/src/App.tsx

Large diffs are not rendered by default.

264 changes: 181 additions & 83 deletions ui/src/index.css

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions ui/src/palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// ⌘K: one box to jump to a bot, a chat or a place in the app.
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Icon } from './ui'

export type PaletteItem = { key: string; label: string; sub?: string; icon: ReactNode; hint?: string; run: () => void }

export function Palette({ items, onClose }: { items: PaletteItem[]; onClose: () => void }) {
const [q, setQ] = useState('')
const [i, setI] = useState(0)
const words = q.toLowerCase().split(/\s+/).filter(Boolean)
const hits = items.filter(it => { const t = `${it.label} ${it.sub ?? ''}`.toLowerCase(); return words.every(w => t.includes(w)) })
const at = Math.max(0, Math.min(i, hits.length - 1))
const list = useRef<HTMLDivElement>(null)
useEffect(() => { list.current?.querySelector('.on')?.scrollIntoView({ block: 'nearest' }) }, [at])
const pick = (it: PaletteItem) => { onClose(); it.run() }
return (
<div className="palette-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="palette" role="dialog" aria-label="Jump to">
<label className="palette-bar"><Icon name="search" size={17} />
<input autoFocus value={q} placeholder="Find a bot, chat or place" aria-label="Find a bot, chat or place" role="combobox"
aria-expanded="true" aria-controls="palette-list" aria-activedescendant={hits[at] ? `pal-${hits[at].key}` : undefined}
onChange={e => { setQ(e.target.value); setI(0) }}
onKeyDown={e => {
if (e.key === 'ArrowDown') setI(Math.min(at + 1, hits.length - 1))
else if (e.key === 'ArrowUp') setI(Math.max(at - 1, 0))
else if (e.key === 'Enter') { if (hits[at]) pick(hits[at]) }
else if (e.key === 'Escape') onClose()
else return
// Not the sheet's Escape too, nor the textarea's caret.
e.preventDefault(); e.stopPropagation()
}} />
<kbd>esc</kbd>
</label>
<div className="palette-list" id="palette-list" role="listbox" ref={list}>
{hits.map((it, n) => (
<button key={it.key} id={`pal-${it.key}`} type="button" tabIndex={-1} role="option" aria-selected={n === at}
className={'palette-item' + (n === at ? ' on' : '')} onMouseMove={() => setI(n)} onClick={() => pick(it)}>
<span className="pi-icon">{it.icon}</span>
<span className="pi-text"><b>{it.label}</b>{it.sub && <small>{it.sub}</small>}</span>
{it.hint && <kbd>{it.hint}</kbd>}
</button>
))}
{!hits.length && <p className="palette-none">Nothing matches “{q.trim()}”.</p>}
</div>
</div>
</div>
)
}
49 changes: 48 additions & 1 deletion ui/src/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const PATHS: Record<string, string> = {
play: 'M7.5 5.5v13l11-6.5-11-6.5Z',
retry:'M4.5 12a7.5 7.5 0 1 0 2.2-5.3L4.5 9m0-4.5V9H9',
branch: 'M6.5 3.5v11m0 0a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm11-6a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm0 0c0 4.5-11 3-11 6',
attach: 'm19.5 11.5-7.4 7.4a4.5 4.5 0 0 1-6.4-6.4l7.8-7.8a3 3 0 0 1 4.2 4.2l-7.8 7.8a1.5 1.5 0 0 1-2.1-2.1l7-7',
}

export function Icon({ name, size = 16, className }: { name: keyof typeof PATHS | string; size?: number; className?: string }) {
Expand Down Expand Up @@ -145,6 +146,42 @@ export function Group({ title, note, children, action }: { title?: ReactNode; no
}

/** An empty state: an icon by name, or anything else (a bot's face) in its place. */
/** A view over the chat (Settings, Memory, a bot's page…): the chat dims behind it; Escape, × or a click beside it closes it. */
/** `head`: a title (and a line under it) for views that don't bring their own. */
/** `nav`: tabs between related sheets (the chat's sections), above the title; `page` resets the scroll when it changes. */
export function Sheet({ label, onClose, wide, head, lead, nav, page, children }: {
label: string; onClose: () => void; wide?: boolean; head?: boolean; lead?: ReactNode; nav?: ReactNode; page?: string; children: ReactNode
}) {
const panel = useRef<HTMLElement>(null)
const close = useRef(onClose)
close.current = onClose
useEffect(() => {
// Focus moves in, unless something inside asked for it (Settings' search box).
if (!panel.current?.contains(document.activeElement)) panel.current?.focus({ preventScroll: true })
const key = (e: KeyboardEvent) => {
// A dialog or menu on top takes its own Escape; a box with text in it clears first.
if (e.key !== 'Escape' || e.defaultPrevented || document.querySelector('.ask-backdrop, .ctx-menu, .palette')) return
const t = e.target
if ((t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) && t.value) return
close.current()
}
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [])
return (
<div className="sheet-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<section ref={panel} tabIndex={-1} className={'sheet' + (wide ? ' wide' : '')} role="dialog" aria-label={label}>
<button type="button" className="sheet-close" onClick={onClose} aria-label={`Close ${label}`} title="Close esc"><Icon name="x" size={15} /></button>
<div className="sheet-scroll" key={page}><div className="col">
{nav && <div className="sheet-nav">{nav}</div>}
{head && <div className="page-head"><div><h1 className="display">{label}</h1>{lead && <p className="lead">{lead}</p>}</div></div>}
{children}
</div></div>
</section>
</div>
)
}

export function Empty({ icon, title, children, actions }: { icon: string | ReactNode; title: ReactNode; children?: ReactNode; actions?: ReactNode }) {
return (
<div className="empty">
Expand Down Expand Up @@ -239,5 +276,15 @@ export function useHoldToTalk(add: (words: string) => void, fail: (e: string) =>
}, 250)
}
const onKeyUp = (e: React.KeyboardEvent) => { if (e.key === TALK_KEY) stop(true) }
return { state, keys: { onKeyDown, onKeyUp, onBlur: () => stop(false) } }
// The microphone button: hold it while you speak, as with the key.
const begin = () => {
if (!inDesktop || started.current) return
setState('listening')
started.current = localInvoke('dictate', { action: 'start' }).then(() => true, err => { fail(String(err)); return false })
}
return { state, keys: { onKeyDown, onKeyUp, onBlur: () => stop(false) }, press: {
onPointerDown: begin, onPointerUp: () => stop(true), onPointerLeave: () => stop(true),
// Focus stays in the text box: moving it would blur the box, and its blur drops the recording just started.
onMouseDown: (e: React.MouseEvent) => e.preventDefault(),
} }
}
7 changes: 0 additions & 7 deletions ui/src/views/cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,6 @@ function useGoal(chatId: number) {
return { goal, setGoal, load }
}

/** One line for the chat's header: the goal and how it stands. */
export function GoalLine({ chatId }: { chatId: number }) {
const { goal } = useGoal(chatId)
if (!goal) return null
return <> · <span title={`Done when ${goal.done_when}`}>Goal{goal.status === 'active' ? '' : ` (${goal.status})`}: {goal.text}</span></>
}

/** The goal pinned above the cards: yours to pause, resume, mark done or remove, whatever the bot is doing. */
function GoalCard({ chatId, botName }: { chatId: number; botName: (id: number) => string }) {
const { goal: g, setGoal, load } = useGoal(chatId)
Expand Down
Loading
Loading