From f220247ab1c159bf4ccb64f803cb11383e0ff3d8 Mon Sep 17 00:00:00 2001 From: Willem Date: Fri, 6 Nov 2026 16:52:46 +0100 Subject: [PATCH 1/2] App UI: bots-first sidebar, chat bubbles, everything else opens over the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar: Bots and Chats with search (⌘K) and new bot at the top; Needs you, Meetings, Computer and Settings in the footer. - Chat: no toolbar. A floating pill with the bot's face and name (opens its page) and corner icons for Memory, Skills, Routines, Activity and search. - Bot replies in bubbles, one face and name per run; your messages tinted with the bot's pigment; a centred time on a new day or after an hour's gap. - Pill composer with attach and hold-to-talk buttons, send in the bot's colour. - Settings, Needs you, the chat's sections, Meetings, Computer and a bot's page open as sheets over the chat (sections share one sheet, with tabs). Settings gets a category nav. ⌘K jumps to a bot, chat or place. - ui-flows.mjs follows the new chrome. Co-Authored-By: Claude Opus 5.5 --- scripts/ui-flows.mjs | 15 +- ui/src/App.tsx | 322 +++++++++++++++++++++----------------- ui/src/index.css | 264 +++++++++++++++++++++---------- ui/src/palette.tsx | 48 ++++++ ui/src/ui.tsx | 45 +++++- ui/src/views/cards.tsx | 7 - ui/src/views/chat.tsx | 101 +++++++----- ui/src/views/settings.tsx | 92 ++++++++--- 8 files changed, 589 insertions(+), 305 deletions(-) create mode 100644 ui/src/palette.tsx diff --git a/scripts/ui-flows.mjs b/scripts/ui-flows.mjs index 77481d0c..84af573a 100644 --- a/scripts/ui-flows.mjs +++ b/scripts/ui-flows.mjs @@ -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 } @@ -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)) @@ -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(' | ')) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d04b6c8e..72ce4029 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,19 +1,19 @@ -import { Component, type ReactNode, useMemo, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Component, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' import { inDesktop, invoke, localInvoke, probe, remoteServer, saveDownload, setWebToken, shortcut, webToken } from './api' import { moodOf, type Mood } from './face' import { ahead, setLooks } from './format' -import { ask, AskHost, tell, Avatar, Icon, Segmented } from './ui' +import { ask, AskHost, tell, Avatar, Icon, Segmented, Sheet } from './ui' import { CHANNEL, useLoad, LOCAL, HOUSEHOLD, Bots, online, presenceOf } from './shared' import type { Tab, Status, Busy, Bot, Chat, Inbox, ComputerStatus, Pause } from './types' import { ActivityView } from './views/activity' import { BotImport, BotPage } from './views/bot' import { Paused, pausedText, ChatView, Menu, pauseChoices } from './views/chat' -import { GoalLine } from './views/cards' import { ComputerView } from './views/computer' import { inboxCount, Approvals, InboxView, SignInForm } from './views/inbox' import { MeetingsView } from './views/meetings' import { MemoryView } from './views/memory' import { Onboarding } from './views/onboarding' +import { Palette, type PaletteItem } from './palette' import { RoutinesView } from './views/routines' import { SettingsView } from './views/settings' import { SkillsView } from './views/skills' @@ -139,11 +139,6 @@ function Main() { const at = inDesktop ? remoteServer()?.url : location.origin if (at && (!inDesktop || remoteServer())) invoke('public_url', { url: at }).catch(() => {}) }, []) - // Content drifts in from the side of the tab you moved to (left or right); other views just rise. - const lastTab = useRef(0) - const tabIndex = TABS.findIndex(t => t.id === view) - const dx = useMemo(() => (tabIndex < 0 ? 0 : Math.sign(tabIndex - lastTab.current) * 14), [view]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { if (tabIndex >= 0) lastTab.current = tabIndex }, [tabIndex]) // The sidebar's selection is one highlight that glides to the chosen bot or chat. const railRef = useRef(null) const railOn = useRef(null) @@ -173,8 +168,6 @@ function Main() { const chat = list.find(c => c.chat_id === chatId) ?? list[0] // The household is memories only: nobody chats there. const household = chat.channel === 'household' - useEffect(() => { if (household && view !== 'memory' && TABS.some(t => t.id === view)) setView('memory') }, [household, view]) - const inChat = view !== 'settings' && view !== 'bot' && view !== 'inbox' && view !== 'computer' && view !== 'meetings' const computer = useLoad(() => invoke('computer'), [], 20000) // A meeting being transcribed (this computer), for the sidebar's dot. const meetingNow = useLoad(() => inDesktop ? localInvoke<{ recording: boolean }>('meeting', { action: 'status' }) : Promise.resolve({ recording: false }), [], 3000) @@ -185,8 +178,8 @@ function Main() { // Clicking a bot opens a chat with it (on this computer); its settings are a right-click (or the gear by the // composer's bot picker) away. const talkTo = (id: number) => { setChatBot(id); setChatId(LOCAL); setIncognito(false); setViaBot(true); setView('chat'); setMenu(null) } - // Opened from a bot: it stays that bot's chat across the section tabs, so the header and sidebar don't jump. - const botChat = viaBot && inChat && chat.chat_id === LOCAL + // The chat stays as it was under whatever opens over it. + const botChat = viaBot && chat.chat_id === LOCAL // A bot's right-click menu, or pause choices (for one bot, or all of them: id null). const [menu, setMenu] = useState<{ x: number; y: number; id: number | null; pick?: boolean } | null>(null) // A bot file being read before it's added (from a file, or a bot's own for Duplicate). @@ -210,9 +203,11 @@ function Main() { // ⌘F: on the Settings page it searches settings; anywhere else, memory. const findIt = () => { - // In Settings or a chat, its own search box; elsewhere, Memory's. + // In Settings or a chat, its own search box (a chat's opens from its button); elsewhere, Memory's. const box = document.querySelector('.settings-search input, .chat-search input') if (box) { box.focus(); box.select(); return } + const open = document.querySelector('.chat-search-toggle') + if (open) { open.click(); return } setView('memory') setTimeout(() => document.querySelector('.search input')?.focus(), 50) } @@ -228,7 +223,9 @@ function Main() { })).then(u => { off = u }, () => {}) return () => off?.() }, []) - // ⌘ on macOS, Ctrl elsewhere: 1–5 sections, 0 Needs you, comma Settings, N new bot, F search (this chat, or memory). + // ⌘K: jump to a bot, a chat or a place. + const [palette, setPalette] = useState(false) + // ⌘ on macOS, Ctrl elsewhere: 1–5 sections, 0 Needs you, comma Settings, N new bot, F search (this chat, or memory), K jump. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return @@ -238,6 +235,7 @@ function Main() { else if (e.key === '0') setView('inbox') else if (e.key === 'n') { setBotId(0); setView('bot') } else if (e.key === 'f') findIt() + else if (e.key === 'k') setPalette(o => !o) else return e.preventDefault() } @@ -247,89 +245,136 @@ function Main() { const chatName = (c: Chat) => c.chat_id === LOCAL ? LOCAL_NAME : c.title || `Chat ${c.chat_id}` const ch = CHANNEL[chat.chat_id === LOCAL ? 'local' : chat.channel] ?? CHANNEL.local + const current = chatBot ?? botList[0]?.id ?? 1 + // A bot's row is lit while you chat with it, or while its page is open over the chat. + const botOn = (id: number) => view === 'bot' ? botId === id : botChat && current === id + const botRow = (b: Bot) => { + const st = statusOf(b.id) + const on = online(st) + const p = presenceOf(st) + // Waiting on you beats working: the reply (or routine) can't go on until you answer. + const asks = approvals.some(a => a.bot_id === b.id) + const w: Busy | undefined = asks ? { bot_id: b.id, chat_id: 0, trigger: 'ask', step: 'Waiting for your OK' } : busy.data?.find(x => x.bot_id === b.id) + const title = titles.data?.find(t => t.bot_id === b.id)?.title + return ( + + ) + } + const openChatHere = (id: number) => { setChatId(id); setViaBot(false); setView('chat') } + const SHEETS: Partial> = { settings: 'Settings', inbox: 'Needs you', computer: 'Computer', meetings: 'Meetings', memory: 'Memory', skills: 'Skills', routines: 'Routines', activity: 'Activity' } + const sheetLabel = view === 'bot' ? (botId === 0 ? 'New bot' : botOf(botId).name) : SHEETS[view] + const talking = botOf(current) + const tools = TABS.filter(t => t.id !== 'chat') + const section = tools.some(t => t.id === view) + // Over the chat, floating: who you're talking to (their page, on a click) and the chat's sections. + const top = ( + <> + + {botChat ? ( + + ) : ( + + {chatName(chat)}{chat.chat_id !== LOCAL && {ch.name}} + + )} +
+ {!botChat && chat.chat_id !== LOCAL && !chat.family && ( + + )} + {/* A guest's own chat can share the household's lists; a group can become one the whole household talks in. */} + {!botChat && chat.chat_id !== LOCAL && chat.private && chat.guest && ( + + )} + {!botChat && chat.chat_id !== LOCAL && chat.private === false && ( + + )} + {tools.map(t => ( + + ))} +
+ + ) + const paletteItems: PaletteItem[] = [ + ...botList.map(b => ({ key: `b${b.id}`, label: b.name, sub: titles.data?.find(t => t.bot_id === b.id)?.title ?? 'Bot', icon: , run: () => talkTo(b.id) })), + ...list.map(c => ({ key: `c${c.chat_id}`, label: chatName(c), sub: `${(CHANNEL[c.chat_id === LOCAL ? 'local' : c.channel] ?? CHANNEL.local).name} · chat`, icon: , run: () => openChatHere(c.chat_id) })), + { key: 'inbox', label: 'Needs you', sub: needs ? `${needs} waiting for you` : 'All clear', icon: , hint: shortcut('0'), run: () => setView('inbox') }, + ...tools.map((t, i) => ({ key: t.id, label: t.label, icon: , hint: shortcut(String(i + 2)), run: () => setView(t.id) })), + { key: 'new', label: 'New bot', icon: , hint: shortcut('N'), run: () => openBot(0) }, + { key: 'settings', label: 'Settings', icon: , hint: shortcut(','), run: () => setView('settings') }, + ...(inDesktop ? [{ key: 'meetings', label: 'Meetings', sub: recording ? 'Transcribing…' : 'Transcribe a call', icon: , run: () => setView('meetings') }] : []), + ...(computer.data?.enabled ? [{ key: 'computer', label: 'Computer', sub: 'Watch or take over', icon: , run: () => setView('computer') }] : []), + allPause ? { key: 'pause', label: 'Resume all bots', icon: , run: () => setPause(null, false) } + : { key: 'pause', label: 'Pause all bots…', icon: , run: () => setMenu({ x: window.innerWidth / 2 - 95, y: 140, id: null, pick: true }) }, + ] + return (
-
- {/* Pages outside a chat carry their own big title: the bar stays only as the window's drag area. */} -
- {inChat ? ( - <> -
- {botChat ? - : } - {botChat ? botOf(chatBot ?? botList[0]?.id ?? 1).name : chatName(chat)}{ch.name} · {chat.memories} {chat.memories === 1 ? 'memory' : 'memories'}{chat.household && !household ? ' + household memories' : ''}{!household && } - {!botChat && chat.chat_id !== LOCAL && !household && !chat.family && ( - - )} - {/* A guest's own chat can share the household's lists; a group can become one the whole household talks in. */} - {!botChat && chat.chat_id !== LOCAL && !household && chat.private && chat.guest && ( - - )} - {!botChat && chat.chat_id !== LOCAL && !household && chat.private === false && ( - - )} -
- t.id === 'memory') : TABS} /> - - ) : null} -
- +
{view !== 'inbox' && setView('inbox')} />} {signIn && { setSignIn(false); inbox.reload() }} />} - {view === 'chat' ? ( - troubleOf(statusOf(id))} busy={busy.data ?? undefined} /> - ) : ( - // Keyed: each view starts at the top (the scroller used to keep the last view's position). -
-
- {view === 'memory' && } - {view === 'skills' && } - {view === 'routines' && } - {view === 'activity' && } - {view === 'settings' && } - {view === 'computer' && } - {view === 'meetings' && } - {view === 'inbox' && setView('settings')} />} - {view === 'bot' && ( - // Keyed on whether the bot has loaded too: right after "Create bot" the list hasn't caught up yet. - b.id === botId)}`} bot={botList.find(b => b.id === botId)} status={statusOf(botId)} canDelete={botList.length > 1} - // Only a new bot's page moves (to the bot it made): a slow save mustn't pull you back from another bot. - onSaved={id => { bots.reload(); statuses.reload(); if (botId === 0) setBotId(id) }} - onDeleted={() => { bots.reload(); if (chatBot === botId) { setChatBot(null); setViaBot(false) } setView('chat') }} onChat={() => talkTo(botId)} onImport={text => setImporting({ text })} /> - )} -
+ {household ? ( + // The household is memories only: nobody chats there. +
+
+ ) : ( + troubleOf(statusOf(id))} busy={busy.data ?? undefined} /> + )} + + {sheetLabel && ( + // Keyed: each view starts at the top. + // The chat's sections share one sheet, with tabs to move between them. + setView('chat')} wide={view === 'settings' || view === 'computer'} + nav={section && !household ? : undefined} + head={view === 'memory' || view === 'routines' || view === 'activity' || view === 'inbox'} + lead={view === 'inbox' ? 'Approvals, questions and anything that stopped working.' : chat.chat_id === LOCAL ? `${LOCAL_NAME}: shared by all your bots` : `${chatName(chat)} · ${ch.name}`}> + {view === 'memory' && } + {view === 'skills' && } + {view === 'routines' && } + {view === 'activity' && } + {view === 'settings' && } + {view === 'computer' && } + {view === 'meetings' && } + {view === 'inbox' && setView('settings')} />} + {view === 'bot' && ( + // Keyed on whether the bot has loaded too: right after "Create bot" the list hasn't caught up yet. + b.id === botId)}`} bot={botList.find(b => b.id === botId)} status={statusOf(botId)} canDelete={botList.length > 1} + // Only a new bot's page moves (to the bot it made): a slow save mustn't pull you back from another bot. + onSaved={id => { bots.reload(); statuses.reload(); if (botId === 0) setBotId(id) }} + onDeleted={() => { bots.reload(); if (chatBot === botId) { setChatBot(null); setViaBot(false) } setView('chat') }} onChat={() => talkTo(botId)} onImport={text => setImporting({ text })} /> + )} + )}
diff --git a/ui/src/index.css b/ui/src/index.css index 72885be4..f1318174 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -39,6 +39,9 @@ --shadow-md: 0 6px 20px -8px rgba(20, 20, 40, 0.14), 0 0 0 1px rgba(20, 20, 30, 0.06); --shadow-lg: 0 18px 44px -12px rgba(20, 20, 40, 0.22), 0 0 0 0.5px rgba(20, 20, 30, 0.09); --radius: 10px; + /* A bot's reply sits on this; yours takes the bot's pigment. */ + --bubble: #EFEFEC; + --scrim: rgba(18, 18, 24, 0.26); } @media (prefers-color-scheme: dark) { @@ -72,6 +75,8 @@ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.115); --shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.03); --shadow-lg: 0 22px 50px -12px rgba(0, 0, 0, 0.65), 0 0 0 0.5px rgba(255, 255, 255, 0.08); + --bubble: #29292D; + --scrim: rgba(0, 0, 0, 0.5); } } @@ -112,7 +117,7 @@ a.btn { text-decoration: none; } /* ---------------- layout ---------------- */ -.app { display: grid; grid-template-columns: 232px 1fr; grid-template-rows: 100%; height: 100%; } +.app { display: grid; grid-template-columns: 252px 1fr; grid-template-rows: 100%; height: 100%; } .rail { display: flex; flex-direction: column; min-height: 0; @@ -122,60 +127,56 @@ a.btn { text-decoration: none; } :root[data-shell="mac"] .rail { background: var(--rail-glass); } @media (prefers-reduced-transparency: reduce) { :root[data-shell="mac"] .rail { background: var(--rail); } } -.rail-top { height: 14px; flex: none; } -:root[data-shell="mac"] .rail-top { height: 52px; } +/* The top of the sidebar: the name (beside the traffic lights on a Mac, it's left out), then search and new bot. */ +.rail-top { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 12px 0 18px; } +:root[data-shell="mac"] .rail-top { justify-content: flex-end; } +.rail-tools { display: flex; gap: 6px; margin-left: auto; } +.rail-tool { + position: relative; width: 30px; height: 30px; flex: none; display: grid; place-items: center; padding: 0; + border: 0; border-radius: 99px; background: none; color: var(--text-2); transition: background-color 140ms ease, color 140ms ease, transform 300ms var(--spring-soft); +} +.rail-tool:hover { background: var(--hover); color: var(--text); } +.rail-tool:active { transform: scale(0.92); } +.rail-tool.on { background: var(--selected); color: var(--text); } +.rail-top .rail-tool { background: var(--hover); } +.rail-top .rail-tool:hover { background: var(--selected); } +.rail-tool .rec-dot { position: absolute; top: 4px; right: 4px; width: 7px; height: 7px; margin: 0; box-shadow: 0 0 0 2px var(--rail); } .rail-scroll { flex: 1; overflow: auto; padding: 0 10px 10px; } -.brand { display: flex; align-items: center; gap: 9px; padding: 4px 8px 14px; font: 600 17px/1 var(--serif); letter-spacing: -0.01em; } +.rail-scroll > .rail-label:first-of-type { padding-top: 4px; } +.brand { display: flex; align-items: center; gap: 9px; min-width: 0; font: 600 17px/1 var(--serif); letter-spacing: -0.01em; } .brand img { width: 20px; height: 20px; border-radius: 5px; box-shadow: 0 0 0 1px var(--line-strong); } @media (prefers-color-scheme: dark) { .brand img { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.22); } } -.brand .where { margin-left: auto; font: 500 10.5px var(--mono); color: var(--text-2); background: var(--hover); padding: 1px 6px; border-radius: 5px; } +.brand .where { font: 500 10.5px var(--mono); color: var(--text-2); background: var(--hover); padding: 1px 6px; border-radius: 5px; } :root[data-shell="mac"] .brand { display: none; } -.rail-label { display: flex; align-items: center; justify-content: space-between; padding: 20px 8px 6px; font-size: 11.5px; font-weight: 600; color: var(--text-3); } -.rail-label button { border: 0; background: none; color: var(--text-2); width: 20px; height: 20px; border-radius: 5px; display: grid; place-items: center; } -.rail-label button:hover { background: var(--hover); color: var(--text); } -.rail-label span { display: flex; gap: 2px; } -/* Pausing everything is a rare act: its button shows when you reach for the header. */ -.rail-label button[aria-label="Pause all bots"] { opacity: 0; transition: opacity 160ms ease; } -.rail-label:hover button, .rail-label:focus-within button { opacity: 1; } -@media (hover: none) { .rail-label button[aria-label="Pause all bots"] { opacity: 1; } } +.rail-label { display: flex; align-items: center; justify-content: space-between; padding: 14px 10px 4px; font-size: 11.5px; font-weight: 600; color: var(--text-3); } .rail-item { - width: 100%; display: flex; align-items: center; gap: 9px; padding: 5px 8px; margin: 1px 0; - border: 0; border-radius: 7px; background: none; text-align: left; color: var(--text); + width: 100%; display: flex; align-items: center; gap: 10px; padding: 6px 8px; margin: 1px 0; + border: 0; border-radius: 10px; background: none; text-align: left; color: var(--text); transition: background-color 120ms ease; } .rail-item:hover { background: var(--hover); } .rail-item:hover .ri-count, .rail-item:focus-visible .ri-count { opacity: .8; } .rail-item.on { background: var(--selected); } .rail-item.on .ri-name { font-weight: 600; } -@media (prefers-color-scheme: dark) { .rail-glide { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); } } .rail-item .ri-text { flex: 1; min-width: 0; display: grid; gap: 1px; } .rail-item .ri-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rail-item .ri-sub { font-size: 12.5px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rail-item .ri-sub.busy { color: var(--text-2); } .rail-item .ri-count { font-size: 11px; color: var(--text-3); opacity: 0; transition: opacity 160ms ease; font-variant-numeric: tabular-nums; } -.rail-item .ri-glyph { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; color: var(--text-2); background: var(--hover); flex: none; } -.rail-hint { padding: 6px 8px; text-wrap: pretty; font-size: 13px; line-height: 1.4; color: var(--text-3); line-height: 1.4; } -.rail-foot { padding: 8px 10px 12px; border-top: 0.5px solid var(--line); } -.rail-foot .rail-item { color: var(--text-2); padding: 9px 8px; } -.rail-foot .badge { margin-left: auto; } +.rail-item .ri-glyph { width: 30px; height: 30px; border-radius: 99px; display: grid; place-items: center; color: var(--text-2); background: var(--hover); flex: none; } +.rail-chat .ri-name { font-weight: 500; } +.rail-hint { padding: 6px 10px; text-wrap: pretty; font-size: 13px; line-height: 1.4; color: var(--text-3); } +.rail-foot { display: flex; align-items: center; gap: 4px; padding: 8px 10px 12px; border-top: 0.5px solid var(--line); } +.rail-foot .rail-item { flex: 1; min-width: 0; width: auto; margin: 0; color: var(--text-2); } +.rail-foot .rail-item .ri-name { font-weight: 500; } +.rail-foot .rail-item.on, .rail-foot .rail-item.waiting { color: var(--text); } +.rail-foot .waiting .icon { color: var(--pending); } +.rail-foot .ri-sub, .rail-foot .badge { flex: none; margin-left: auto; } .main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); position: relative; } -.toolbar { - height: 52px; flex: none; display: flex; align-items: center; gap: 12px; padding: 0 20px; - border-bottom: 0.5px solid var(--line); - background: color-mix(in srgb, var(--bg) 82%, transparent); - backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); - position: relative; z-index: 5; -} -.toolbar-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; } -.toolbar.bare { border-bottom-color: transparent; background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; } -.toolbar-title .tt { display: grid; min-width: 0; } -.toolbar-title b { font-size: 14px; font-weight: 650; letter-spacing: -0.005em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.toolbar-title small { font-size: 12px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .page { flex: 1; min-height: 0; overflow: auto; } .col { max-width: 760px; margin: 0 auto; padding: 28px 32px 64px; } .col.wide { max-width: 980px; } @@ -249,7 +250,7 @@ a.btn { text-decoration: none; } .switch:disabled { opacity: 0.5; } .segmented { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--hover); flex: none; } -.toolbar .segmented { background: color-mix(in srgb, var(--text) 4.5%, transparent); } +.sheet-nav .segmented { background: color-mix(in srgb, var(--text) 4.5%, transparent); } .segmented button { height: 28px; padding: 0 12px; border: 0; border-radius: 6px; background: none; display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; color: var(--text-2); transition: background-color 120ms ease, color 120ms ease; @@ -360,13 +361,39 @@ a.btn { text-decoration: none; } /* ---------------- chat ---------------- */ .chat { display: flex; flex-direction: column; min-height: 100%; } -.thread { flex: 1; max-width: 760px; width: 100%; margin: 0 auto; padding: 18px 32px 12px; display: flex; flex-direction: column; gap: 18px; } -.chat-search { position: sticky; top: 0; z-index: 3; max-width: 760px; width: 100%; margin: 0 auto; padding: 16px 32px 0; background: linear-gradient(to top, transparent, var(--bg) 30%); } -.chat-search .search { display: block; max-width: 240px; margin-left: auto; } -.chat-search .input { height: 32px; font-size: 13px; background: transparent; border-color: var(--line); box-shadow: none; } -.chat-search .input:hover { background: var(--hover); } -.chat-search .input:focus { background: var(--field); } -.chat-search .transcript { margin: 6px 0 0; max-height: 320px; box-shadow: var(--shadow-md); } +.thread { flex: 1; max-width: 760px; width: 100%; margin: 0 auto; padding: 6px 32px 12px; display: flex; flex-direction: column; gap: 16px; } +/* Over the chat, floating: who you're talking to in the middle, the chat's sections at the right. */ +.chat-top { + position: sticky; top: 0; z-index: 6; height: 58px; flex: none; display: flex; align-items: center; gap: 6px; padding: 0 14px; + background: linear-gradient(to bottom, var(--bg) 50%, color-mix(in srgb, var(--bg) 0%, transparent)); +} +.chat-pill { + position: absolute; left: 50%; top: 12px; transform: translateX(-50%); max-width: min(46%, 360px); + display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 15px 0 6px; border-radius: 99px; + border: 0.5px solid var(--line-strong); background: color-mix(in srgb, var(--surface) 86%, transparent); color: var(--text); font: inherit; + backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); box-shadow: var(--shadow-md); + transition: box-shadow 200ms ease, transform 300ms var(--spring-soft); +} +button.chat-pill:hover { box-shadow: var(--shadow-lg); } +button.chat-pill:active { transform: translateX(-50%) scale(0.97); } +.chat-pill b { font: 600 14px/1 var(--serif); letter-spacing: -0.005em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.chat-pill small { font-size: 11.5px; color: var(--text-3); white-space: nowrap; } +.pill-glyph { width: 24px; height: 24px; border-radius: 99px; display: grid; place-items: center; flex: none; background: var(--hover); color: var(--text-2); } +.chat-tools { margin-left: auto; display: flex; align-items: center; gap: 2px; } +.chat-tools .btn { margin-right: 6px; } +.chat-tools .guest-on { color: var(--pending); background: var(--pending-soft); } +.chat-tool { + width: 32px; height: 32px; flex: none; display: grid; place-items: center; padding: 0; border: 0; border-radius: 99px; + background: none; color: var(--text-3); transition: background-color 140ms ease, color 140ms ease, transform 300ms var(--spring-soft); +} +.chat-tool:hover { background: var(--hover); color: var(--text); } +.chat-tool:active { transform: scale(0.92); } +.chat-tool.on { background: var(--selected); color: var(--text); } +.phone-back { display: none; } +.chat-search { position: relative; width: 230px; animation: pop 260ms var(--spring) both; } +.chat-search .search { display: block; } +.chat-search .input { height: 32px; font-size: 12.5px; border-radius: 99px; } +.chat-search .transcript { position: absolute; right: 0; top: 40px; width: min(440px, 80vw); margin: 0; max-height: 320px; box-shadow: var(--shadow-lg); } .chat-search .tl { grid-template-columns: 84px 86px 1fr; } .chat-search .tl .mono { white-space: nowrap; } .show-earlier { align-self: center; } @@ -374,9 +401,23 @@ a.btn { text-decoration: none; } .so-far b { color: var(--text-2); font-weight: 500; font-size: 12.5px; } .so-far span { color: var(--text-3); } .msg-hit { outline: 2px solid var(--pending); outline-offset: 4px; border-radius: 10px; transition: outline-color 600ms; } -.msg-user { align-self: flex-end; max-width: 78%; padding: 8px 14px; border-radius: 16px 16px 5px 16px; background: var(--sunken); font-size: 15px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; } -.msg-bot { display: grid; grid-template-columns: 26px 1fr; gap: 11px; } -.msg-head { display: flex; align-items: baseline; gap: 8px; margin: 3px 0 3px; } +.msg-user { + align-self: flex-end; max-width: 74%; padding: 8px 14px; border-radius: 18px 18px 6px 18px; font-size: 15px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; + background: color-mix(in oklab, var(--pig, var(--text-3)) 20%, var(--surface)); + box-shadow: inset 0 0 0 0.5px color-mix(in oklab, var(--pig, var(--text-3)) 34%, transparent); +} +@media (prefers-color-scheme: dark) { .msg-user { background: color-mix(in oklab, var(--pig, var(--text-3)) 34%, var(--bg)); } } +.msg-user a { color: inherit; } +.msg-bot { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 10px; } +/* The same bot again, straight after: one face and name for the run, bubbles stacked close. */ +.msg-bot.cont { margin-top: -11px; } +.msg-main { min-width: 0; } +.msg-head { display: flex; align-items: baseline; gap: 8px; margin: 2px 0 4px 3px; } +.msg-line { display: flex; align-items: flex-end; gap: 4px; } +.msg-bubble { min-width: 0; max-width: min(100%, 620px); padding: 9px 14px; border-radius: 6px 18px 18px 18px; background: var(--bubble); } +.msg-bot.cont .msg-bubble { border-radius: 18px; } +.msg-bubble .typing { padding: 4px 2px; } +.msg-tools { display: flex; flex: none; padding-bottom: 2px; } .msg-head b { font-size: 13px; font-weight: 650; color: color-mix(in srgb, var(--pig, var(--text)) 78%, var(--text)); } .msg-head time { font-size: 12px; color: var(--text-3); } .msg-body { font-size: 15px; line-height: 1.6; word-wrap: break-word; } @@ -476,10 +517,8 @@ a.btn { text-decoration: none; } @keyframes shimmer { to { background-position: -200% 0; } } .composer .send.stop { background: var(--text); } .composer .send.stop .stop-square { width: 10px; height: 10px; border-radius: 2.5px; background: var(--bg); } -.day-sep { display: flex; align-items: center; gap: 12px; margin: 6px 0 -4px; font-size: 11.5px; font-weight: 600; color: var(--text-3); } -.day-sep::before, .day-sep::after { content: ""; flex: 1; height: 1px; background: linear-gradient(to right, color-mix(in srgb, var(--line) 30%, transparent), var(--line) 40%); } -.day-sep::after { background: linear-gradient(to left, color-mix(in srgb, var(--line) 30%, transparent), var(--line) 40%); } -.msg-copy { margin-left: auto; width: 22px; height: 22px; border: 0; border-radius: 6px; background: none; color: var(--text-3); display: grid; place-items: center; opacity: 0; transform: scale(0.9); transition: opacity 160ms ease, transform 300ms var(--spring), background-color 120ms ease, color 120ms ease; } +.day-sep { display: flex; justify-content: center; margin: 8px 0 -2px; font-size: 11px; font-weight: 500; color: var(--text-3); font-variant-numeric: tabular-nums; } +.msg-copy { width: 24px; height: 22px; border: 0; border-radius: 6px; background: none; color: var(--text-3); display: grid; place-items: center; opacity: 0; transform: scale(0.9); transition: opacity 160ms ease, transform 300ms var(--spring), background-color 120ms ease, color 120ms ease; } .msg-bot:hover .msg-copy, .msg-copy:focus-visible, .msg-copy.done { opacity: 1; transform: none; } .msg-copy:hover { background: var(--hover); color: var(--text); } .msg-copy + .msg-copy { margin-left: 0; } @@ -511,21 +550,26 @@ a.btn { text-decoration: none; } @keyframes typing { 0%, 60%, 100% { opacity: .3; transform: none; } 30% { opacity: 1; transform: translateY(-2px); } } @media (prefers-reduced-motion: reduce) { .typing i { animation: none; opacity: .6; } .fade-in, .approval { animation: none; } } -.composer-wrap { position: sticky; bottom: 0; padding: 10px 32px 24px; background: linear-gradient(to bottom, transparent, var(--bg) 26%); } +.composer-wrap { position: sticky; bottom: 0; padding: 10px 32px 18px; background: linear-gradient(to bottom, transparent, var(--bg) 26%); } .composer { - max-width: 696px; margin: 0 auto; display: flex; align-items: flex-end; gap: 8px; padding: 6px 6px 6px 8px; - border-radius: 16px; background: var(--surface); border: 1px solid color-mix(in srgb, var(--line-strong) 75%, transparent); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03), 0 8px 24px -14px rgba(0, 0, 0, 0.14); + max-width: 696px; margin: 0 auto; display: flex; align-items: flex-end; gap: 4px; padding: 6px; + border-radius: 24px; background: var(--surface); box-shadow: var(--shadow-md); border: 0.5px solid var(--line-strong); +} +.composer-btn { + width: 32px; height: 32px; flex: none; display: grid; place-items: center; padding: 0; border: 0; border-radius: 99px; + background: none; color: var(--text-3); transition: background-color 120ms ease, color 120ms ease; } +.composer-btn:hover { background: var(--hover); color: var(--text); } +.composer-btn.on { background: var(--danger-soft); color: var(--danger); } .composer:focus-within { border-color: color-mix(in srgb, var(--accent) 60%, var(--line-strong)); } -.composer textarea { flex: 1; border: 0; background: none; resize: none; padding: 5px 4px 5px 4px; font-size: 14px; line-height: 1.45; max-height: 160px; outline: none; } +.composer textarea { flex: 1; border: 0; background: none; resize: none; padding: 6px 4px; font-size: 14px; line-height: 1.45; max-height: 160px; outline: none; } .composer textarea::placeholder { color: var(--text-3); } -.composer .send { width: 30px; height: 30px; border-radius: 50%; border: 0; display: grid; place-items: center; background: var(--accent-fill); color: #fff; flex: none; transition: transform 90ms ease, opacity 120ms; } +.composer .send { width: 32px; height: 32px; border-radius: 50%; border: 0; display: grid; place-items: center; background: var(--pig, var(--ink)); color: #fff; flex: none; transition: transform 90ms ease, opacity 120ms; } .composer .send:disabled { opacity: 1; background: color-mix(in srgb, var(--text) 14%, transparent); color: var(--text-3); } .composer .send:active { transform: scale(0.92); } .bot-pick { position: relative; flex: none; } .bot-pick select { position: absolute; inset: 0; opacity: 0; } -.bot-pick .avatar { margin: 3px 1px -1px; } +.bot-pick .avatar { margin: 2px; } .attach-row { max-width: 696px; margin: 0 auto 8px; display: flex; gap: 8px; } .attach { position: relative; width: 60px; height: 60px; border-radius: 12px; overflow: hidden; box-shadow: var(--shadow-sm); animation: pop 340ms var(--spring-soft) both; } .attach img { width: 100%; height: 100%; object-fit: cover; display: block; } @@ -583,7 +627,7 @@ a.btn { text-decoration: none; } .mem-check { margin: 8px 0 0; font-size: 12px; color: var(--text-2); } .rh .linkish, .mem-check .linkish, .routine-form .linkish { font: inherit; background: none; border: 0; padding: 0; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 2px; color: inherit; } .mem-meta .inline-date::-webkit-calendar-picker-indicator { opacity: .45; margin-left: 2px; cursor: pointer; } -.incog-toggle { flex: none; width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 8px; background: none; color: var(--text-3); cursor: pointer; transition: background-color 120ms ease, color 120ms ease; } +.incog-toggle { flex: none; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 99px; background: none; color: var(--text-3); cursor: pointer; transition: background-color 120ms ease, color 120ms ease; } .incog-toggle:hover { background: var(--hover); color: var(--text-2); } .incog-toggle.on { color: var(--text); background: var(--hover); } .composer.incog { background: color-mix(in srgb, var(--surface) 82%, var(--ink) 18%); } @@ -799,12 +843,16 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p .app { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } .rail, .app.list .main { display: none; } .app.list .rail { display: flex; border-right: 0; } - .rail-top { height: max(14px, env(safe-area-inset-top)); } - /* The section switcher gets its own row (it scrolls sideways) so the chat's name still fits. */ - .toolbar { height: auto; min-height: 52px; flex-wrap: wrap; row-gap: 8px; padding: max(8px, env(safe-area-inset-top)) 16px 8px; } - .toolbar .segmented { flex: 1 0 100%; overflow-x: auto; scrollbar-width: none; } + .rail-top { height: auto; padding-top: max(10px, env(safe-area-inset-top)); padding-bottom: 6px; } .rail-item { min-height: 48px; padding: 8px 10px; } - .rail-label button { width: 36px; height: 36px; } + .rail-tool { width: 38px; height: 38px; } + .chat-top { height: auto; min-height: 58px; padding-top: env(safe-area-inset-top); } + .chat-pill { position: static; transform: none; max-width: none; min-width: 0; flex: 0 1 auto; } + button.chat-pill:active { transform: scale(0.97); } + .phone-back { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border: 0; border-radius: 99px; background: none; color: var(--text-2); transform: scaleX(-1); } + .chat-tool { width: 36px; height: 36px; } + /* Memory and Routines are in the tab bar; the chat's name gets the room. */ + .chat-tools .btn, .chat-search, .chat-search-toggle, .chat-tool[aria-label="Memory"], .chat-tool[aria-label="Routines"] { display: none; } .tabbar { display: grid; grid-template-columns: repeat(4, 1fr); padding-bottom: env(safe-area-inset-bottom); @@ -934,7 +982,7 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p /* Sidebar: one highlight glides between the selected bot / chat. */ .rail-scroll { position: relative; } .rail-glide { - position: absolute; left: 10px; right: 10px; top: 0; height: var(--gh, 0); border-radius: 7px; background: var(--selected); + position: absolute; left: 10px; right: 10px; top: 0; height: var(--gh, 0); border-radius: 10px; background: var(--selected); transform: translateY(var(--gy, 0)); opacity: 0; pointer-events: none; transition: transform 460ms var(--spring-soft), height 460ms var(--spring-soft), opacity 200ms ease; } @@ -962,7 +1010,7 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p @media (prefers-reduced-motion: reduce) { :root { --rise: 0px; --pop: 1; } - .main { --dx: 0px !important; } + .sheet, .palette { animation-name: fade-only !important; } .rail-glide { transition-duration: 1ms; } .quick.summon { animation-name: fade-in-only; } @keyframes fade-in-only { from { opacity: 0; } } @@ -988,7 +1036,7 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p /* Reduce transparency: solid bars instead of blur. */ @media (prefers-reduced-transparency: reduce) { - .toolbar { background: var(--bg); backdrop-filter: none; -webkit-backdrop-filter: none; } + .chat-pill { background: var(--surface); backdrop-filter: none; -webkit-backdrop-filter: none; } } /* "@" in the composer: pick which bot the message goes to. */ @@ -1096,8 +1144,6 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p .ctx-menu button { display: flex; align-items: center; gap: 8px; padding: 6px 9px; border: 0; border-radius: 6px; background: none; color: var(--text); font: inherit; font-size: 13px; text-align: left; cursor: default; } .ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--hover); outline: none; } .ctx-menu button svg { color: var(--text-2); } -.bot-settings { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border: 0; border-radius: 8px; background: none; color: var(--text-3); } -.bot-settings:hover, .bot-settings:focus-visible { background: var(--hover); color: var(--text); } .guarded.trouble { background: color-mix(in srgb, var(--danger-soft) 100%, var(--bg)); color: var(--text); } .guarded.trouble .icon { color: var(--danger); } .page-head .head-action { margin-left: auto; align-self: flex-start; margin-top: 2px; display: inline-flex; align-items: center; gap: 6px; } @@ -1109,7 +1155,6 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p .library .mem-actions.lib-add { opacity: 1; transform: none; } .library .mem .mem-title { padding-right: 80px; } .file-text { white-space: pre-wrap; font: 12px var(--mono); margin: 0; } -.toolbar .guest-on { color: var(--pending); background: var(--pending-soft); } /* The in-app "are you sure?" dialog (ask() in ui.tsx). */ .ask-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 55%, transparent); animation: fade-in 120ms ease-out both; } @@ -1164,12 +1209,6 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p .bot-form .group:has(> .row-block > .field-label) { margin-top: 4px; } .bot-lead { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 520px; } .sched-when { color: var(--text-3); } -/* Chat search: a quiet icon until you reach for it (⌘F opens it too). */ -.chat-search .search { width: 44px; max-width: 240px; transition: width 260ms var(--spring); } -.chat-search .search:not(:hover):not(:focus-within):has(input:placeholder-shown) .icon { left: 27px; } -.chat-search .search:hover, .chat-search .search:focus-within, .chat-search .search:has(input:not(:placeholder-shown)) { width: 240px; } -.chat-search .search:not(:hover):not(:focus-within):has(input:placeholder-shown) .input { border-color: transparent; color: transparent; } -.chat-search .search:not(:hover):not(:focus-within):has(input:placeholder-shown) .input::placeholder { color: transparent; } @media (prefers-color-scheme: dark) { .provider-card:not(.on) { border-color: var(--line); } .provider-card.on { background: color-mix(in srgb, var(--accent-fill) 14%, var(--surface)); box-shadow: inset 0 0 0 1px var(--accent); } } .searchbar .btn-ghost { border-color: transparent; color: var(--text-2); } @@ -1177,13 +1216,6 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p /* Library cards: "Added" and "Add" share one size and line; the state is neutral, not a second accent. */ .library .mem-actions.lib-add .badge.live { height: 30px; padding: 0 2px 0 11px; font-size: 13px; font-weight: 600; background: none; color: var(--text-2); } .library .mem-actions.lib-add .badge.live .icon { color: var(--text-3); } -/* Chat search overlays the top of the thread instead of taking a row. */ -.chat-search { height: 0; padding-top: 0; overflow: visible; } -.chat-search .search { position: relative; top: 38px; } - -.chat-search .search:not(:hover):not(:focus-within):has(input:placeholder-shown) .input { background: none; } -.chat-search .search:hover .input { background: var(--hover); } -.chat-search .search .icon { color: var(--text-2); } /* Light: an off switch you can see on white. */ @media (prefers-color-scheme: light) { .switch:not(:checked) { background: #BBBBC2; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); } } @@ -1250,8 +1282,6 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p .group-wrap:not(:has(.field-label)) > .group:has(+ .group-note) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .group-wrap:not(:has(.field-label)) > .group-note { margin: 0; max-width: none; padding: 10px 120px 11px 52px; background: color-mix(in srgb, var(--text) 1.5%, var(--surface)); box-shadow: var(--shadow-sm); clip-path: inset(0 -3px -4px -3px); border-radius: 0 0 12px 12px; border-top: 1px solid var(--line); text-wrap: pretty; } -.so-far { padding-right: 48px; } - /* An input bar (a field and its buttons in one card row): the field fills the row; on a phone the buttons wrap under it. */ .row:has(> .input) { flex-wrap: wrap; } .row > .input { flex: 1 1 120px; min-width: 120px; } @@ -1266,3 +1296,71 @@ kbd, .keys { font: 500 11.5px var(--sans); color: var(--text-2); padding: 3px 7p .approval .choices { flex-wrap: wrap; max-width: 300px; } /* Needs you: every section after the first gets the usual gap above its heading. */ .inbox > * + .section-title { margin-top: 32px !important; } +/* ---------------- sheets: everything that isn't the chat opens over it ---------------- */ + +.sheet-backdrop { + position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; padding: 22px 28px; + background: var(--scrim); animation: fade-only 200ms ease-out both; +} +@keyframes fade-only { from { opacity: 0; } } +.sheet { + position: relative; width: min(860px, 100%); height: min(100%, 880px); display: flex; flex-direction: column; overflow: hidden; outline: none; + border-radius: 18px; background: var(--bg); border: 0.5px solid var(--line-strong); box-shadow: var(--shadow-lg); + animation: sheet-in 420ms var(--spring) both; +} +.sheet.wide { width: min(1060px, 100%); } +@keyframes sheet-in { from { opacity: 0; transform: translateY(14px) scale(0.985); } } +.sheet-scroll { flex: 1; min-height: 0; overflow: auto; } +.sheet .col, .sheet .col.wide { max-width: none; padding: 30px 44px 56px; } +.sheet .page-head { padding-right: 40px; } +.sheet-nav { margin: -8px 0 22px; } +.sheet-close { + position: absolute; top: 14px; right: 14px; z-index: 3; width: 30px; height: 30px; display: grid; place-items: center; padding: 0; + border: 0; border-radius: 99px; background: var(--hover); color: var(--text-2); transition: background-color 120ms ease, color 120ms ease; +} +.sheet-close:hover { background: var(--selected); color: var(--text); } + +/* Settings: a nav of its parts at the left, the groups at the right. */ +.settings-layout { display: grid; grid-template-columns: 184px minmax(0, 1fr); gap: 32px; align-items: start; } +.settings-nav { position: sticky; top: 0; display: grid; gap: 2px; padding-top: 2px; } +.settings-nav button { + display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 10px; border: 0; border-radius: 9px; background: none; + color: var(--text-2); font-size: 13px; text-align: left; white-space: nowrap; transition: background-color 120ms ease, color 120ms ease; +} +.settings-nav button:hover { background: var(--hover); color: var(--text); } +.settings-nav button.on { background: var(--selected); color: var(--text); font-weight: 550; } +.settings-nav button .icon { color: var(--text-3); } +.settings-nav button.on .icon { color: var(--text); } +.set-cat { scroll-margin-top: 20px; } +/* A page's save bar reaches the sheet's edges. */ +.sheet .savebar { margin: 0 -44px -56px; padding: 12px 44px; background: color-mix(in srgb, var(--bg) 94%, transparent); } + +@media (max-width: 760px) { + /* Phone: a sheet is the whole screen. */ + .sheet-backdrop { padding: 0; } + .sheet, .sheet.wide { width: 100%; height: 100%; border-radius: 0; border: 0; } + .sheet-close { top: max(10px, env(safe-area-inset-top)); } + .sheet .col, .sheet .col.wide { padding: max(18px, env(safe-area-inset-top)) 18px 40px; } + .sheet .savebar { margin: 0 -18px -40px; padding: 12px 18px; } + .settings-layout { grid-template-columns: 1fr; gap: 12px; } + .settings-nav { position: static; display: flex; overflow-x: auto; scrollbar-width: none; } + .settings-nav button { flex: none; } +} + +/* ⌘K: jump anywhere. */ +.palette-backdrop { position: fixed; inset: 0; z-index: 90; display: flex; justify-content: center; align-items: flex-start; padding-top: 13vh; background: var(--scrim); animation: fade-only 160ms ease-out both; } +.palette { + width: min(580px, calc(100vw - 32px)); overflow: hidden; border-radius: 16px; background: var(--surface); border: 0.5px solid var(--line-strong); + box-shadow: var(--shadow-lg); animation: summon 320ms var(--spring) both; transform-origin: top center; +} +.palette-bar { display: flex; align-items: center; gap: 10px; height: 54px; padding: 0 14px 0 18px; border-bottom: 0.5px solid var(--line); color: var(--text-3); } +.palette-bar input { flex: 1; min-width: 0; border: 0; background: none; outline: none; font-size: 15.5px; color: var(--text); } +.palette-bar input::placeholder { color: var(--text-3); } +.palette-list { max-height: min(430px, 58vh); overflow: auto; padding: 6px; } +.palette-item { width: 100%; display: flex; align-items: center; gap: 11px; padding: 7px 10px; border: 0; border-radius: 10px; background: none; color: var(--text); text-align: left; } +.palette-item.on { background: var(--selected); } +.pi-icon { width: 28px; height: 28px; flex: none; display: grid; place-items: center; color: var(--text-2); } +.pi-text { flex: 1; min-width: 0; display: grid; } +.pi-text b { font-size: 13px; font-weight: 550; } +.pi-text small { font-size: 11.5px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.palette-none { margin: 0; padding: 18px 12px; font-size: 13px; color: var(--text-3); } diff --git a/ui/src/palette.tsx b/ui/src/palette.tsx new file mode 100644 index 00000000..c6a7a51b --- /dev/null +++ b/ui/src/palette.tsx @@ -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(null) + useEffect(() => { list.current?.querySelector('.on')?.scrollIntoView({ block: 'nearest' }) }, [at]) + const pick = (it: PaletteItem) => { onClose(); it.run() } + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+ +
+ {hits.map((it, n) => ( + + ))} + {!hits.length &&

Nothing matches “{q.trim()}”.

} +
+
+
+ ) +} diff --git a/ui/src/ui.tsx b/ui/src/ui.tsx index 704140d4..caeb3fd4 100644 --- a/ui/src/ui.tsx +++ b/ui/src/ui.tsx @@ -55,6 +55,7 @@ const PATHS: Record = { 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 }) { @@ -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(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 ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+ +
+ {nav &&
{nav}
} + {head &&

{label}

{lead &&

{lead}

}
} + {children} +
+
+
+ ) +} + export function Empty({ icon, title, children, actions }: { icon: string | ReactNode; title: ReactNode; children?: ReactNode; actions?: ReactNode }) { return (
@@ -239,5 +276,11 @@ 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) } } } diff --git a/ui/src/views/cards.tsx b/ui/src/views/cards.tsx index a3432906..f9f2058d 100644 --- a/ui/src/views/cards.tsx +++ b/ui/src/views/cards.tsx @@ -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 <> · Goal{goal.status === 'active' ? '' : ` (${goal.status})`}: {goal.text} -} - /** 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) diff --git a/ui/src/views/chat.tsx b/ui/src/views/chat.tsx index 5379c3ab..db1ac45d 100644 --- a/ui/src/views/chat.tsx +++ b/ui/src/views/chat.tsx @@ -1,8 +1,8 @@ import { createPortal } from 'react-dom' -import { createContext, Fragment, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { copyText, inDesktop, invoke, platform } from '../api' +import { createContext, Fragment, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { copyText, inDesktop, invoke, shortcut } from '../api' import { moodOf, type Mood } from '../face' -import { ago, ahead, dayLabel, fullTime, IDLE, pigment, schedule } from '../format' +import { ago, ahead, dayLabel, fullTime, IDLE, pigment, schedule, time } from '../format' import { Markdown, SentFile, splitSent } from '../markdown' import { PinnedCards } from './cards' import { ToolCard, type ToolCardData } from './toolcard' @@ -301,8 +301,10 @@ export function Menu({ at, name, items, onClose }: { at: { x: number; y: number ) } -export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, openBot, openSettings, botId: chosen, onBot, troubleOf: trouble, only = false, busy: running = [] }: { +export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, openBot, openSettings, botId: chosen, onBot, troubleOf: trouble, only = false, busy: running = [], top }: { chat: Chat; bots: Bot[]; onTurn: () => void; incognito?: boolean; setIncognito?: (on: boolean) => void; openBot?: (id: number) => void + // The floating header's content (who you're talking to, the chat's sections); the chat adds its search to it. + top?: ReactNode openSettings?: (find: string) => void // Opened from a bot in the sidebar: only that bot's conversation, not everyone's in this chat. only?: boolean @@ -367,6 +369,7 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, // All the way down: the composer floats over the thread's end, so scrolling a marker into view would hide the last message. const toEnd = (smooth = false) => scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }) const box = useRef(null) + const file = useRef(null) const local = chat.chat_id === LOCAL const msgs = incognito ? secret : log.data ?? [] const conv = useLoad(() => invoke<{ tainted: boolean; title: string | null; so_far: string | null }>('conversation', { botId, chatId: cid }), [botId, cid], 4000) @@ -425,6 +428,7 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, // Search this chat: a hit loads what's needed and scrolls to it, lit for a moment. const [query, setQuery] = useState('') + const [searching, setSearching] = useState(false) const [hits, setHits] = useState<{ id: number; ts: number; bot_id: number; role: string; excerpt: string }[] | null>(null) useEffect(() => { const q = query.trim() @@ -434,7 +438,7 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, }, [query, chat.chat_id, mine, incognito]) const target = useRef(null) const jump = (id: number) => { - setQuery('') + setQuery(''); setSearching(false) target.current = id setLit(id) setTimeout(() => setLit(l => (l === id ? null : l)), 2400) @@ -568,7 +572,8 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, // Swap the pending bubbles for the saved messages in one frame: no flash in between. .finally(() => { ownSend.current = true; log.reload().finally(() => { setPending(null); setTo(null) }); conv.reload(); onTurn(); if (only && to !== botId) onBot?.(to) }) } - const bubble = (m: Message, last = false) => { + // cont: the same bot's reply right after its last one: one face and name for the run. + const bubble = (m: Message, last = false, cont = false) => { const sched = m.role === 'user' ? m.content.match(SCHEDULED) : null if (sched) return (
@@ -583,17 +588,23 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, Task from {handed[1]} — {handed[2]}
) - if (m.role === 'user') return
+ if (m.role === 'user') return
const b = botOf(m.bot_id) const [text, files] = splitSent(m.content) return ( -
- -
-
{b.name} - {local && !incognito && !working && (!only || m.bot_id === botId) && } -
- {text &&
} +
+ {cont ? : } +
+ {!cont &&
{b.name}
} + {text && ( +
+
+ + {local && !incognito && !working && (!only || m.bot_id === botId) && } + + +
+ )} {m.cards?.map(c => { setText(t); box.current?.focus() }} />)} {files.map(f => )} {/* Only under the latest message: once anything follows, it's been answered one way or another. */} @@ -618,12 +629,14 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, return (
- {!incognito && botOf(id).name} />} - {!incognito && (msgs.length > 0 || hasEarlier) && ( +
+ {top} + {!incognito && (msgs.length > 0 || hasEarlier) && (searching ? (
+ setQuery(e.target.value)} onBlur={() => { if (!query.trim()) setSearching(false) }} + onKeyDown={e => { if (e.key === 'Escape') { if (!query) setSearching(false); setQuery('') } }} /> {hits && query.trim() && (
{hits.length ? hits.map(h => (
)}
- )} + ) : ( + + ))} +
+ {!incognito && botOf(id).name} />}
{hasEarlier && msgs.length > 0 && } {back && msgs.length > 0 &&

{back.title && {back.title}}So far: {back.line}

} @@ -649,19 +666,24 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, Messages with this {CHANNEL[chat.channel]?.name ?? ''} chat show up here. ) )} - {msgs.map((m, i) => ( - - {(i === 0 || dayLabel(m.ts) !== dayLabel(msgs[i - 1].ts)) &&
{dayLabel(m.ts)}
} - {bubble(m, i === msgs.length - 1)} -
- ))} + {msgs.map((m, i) => { + const prev = msgs[i - 1] + // A new day, or a pause of over an hour: when it was, in the middle. + const sep = !prev || dayLabel(m.ts) !== dayLabel(prev.ts) || m.ts - prev.ts > 3600 + return ( + + {sep &&
{dayLabel(m.ts)} {time(new Date(m.ts * 1000))}
} + {bubble(m, i === msgs.length - 1, !sep && m.role === 'assistant' && prev.role === 'assistant' && prev.bot_id === m.bot_id)} +
+ ) + })} {working && ( <> - {pending &&
} - {steered.map((t, i) =>
)} + {pending &&
} + {steered.map((t, i) =>
)}
- -
{them.name}{draft && } + +
{them.name}{draft && } {now - started >= 5 && }
{checklist.length > 0 && (
    c.done).length} of ${checklist.length} done`}> @@ -674,7 +696,8 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, {step &&
  1. {step}…
  2. }
)} - {draft ?
: !step && }
+ {draft ?
+ : !step &&
}
)} @@ -741,7 +764,7 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, ))}
)} -
{ e.preventDefault(); send() }} + { e.preventDefault(); send() }} style={{ '--pig': pigment(me.id) } as React.CSSProperties} // Photos are saved to disk, so incognito doesn't take them. onDragOver={e => { if (!incognito && e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDragging(true) } }} onDragLeave={() => setDragging(false)} @@ -755,10 +778,12 @@ export function ChatView({ chat, bots, onTurn, incognito = false, setIncognito, )} - {openBot && !incognito && ( - + {!incognito && ( + <> + + { if (e.target.files) addPhotos([...e.target.files]); e.target.value = '' }} /> + )}