Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269<script lang="ts" module> /** * Where each window's chat was read when it unmounted (a board switch * remounts the whole board): the block at the top of the thread and how * far through it the reader was, as `reading` holds it. A chat left at its end keeps no place. */ const places = new Map<string, { block: string; share: number }>();
type Piece = { kind: 'text' | 'quote' | 'code'; text: string }; /** Messages split by their text: the chat draws every bubble again for each token of a reply. */ const pieced = new Map<string, Piece[]>();
/** * A message as you wrote it, with the quotes and fenced code a sent note * carries (notes.ts `compose`) set apart from the words around them. */ function pieces(text: string): Piece[] { const known = pieced.get(text); if (known) return known; const found: { kind: 'text' | 'quote' | 'code'; lines: string[] }[] = []; let fenced = false; for (const line of text.split('\n')) { if (line.startsWith('```')) { fenced = !fenced; if (fenced) found.push({ kind: 'code', lines: [] }); continue; } const kind = fenced ? 'code' : /^> ?/.test(line) ? 'quote' : 'text'; const body = kind === 'quote' ? line.replace(/^> ?/, '') : line; const last = found.at(-1); if (last?.kind === kind) last.lines.push(body); else found.push({ kind, lines: [body] }); } const split = found.map(({ kind, lines }) => ({ kind, text: kind === 'code' ? lines.join('\n') : lines.join('\n').replace(/^\n+|\s+$/g, '') })).filter((piece) => piece.text); if (pieced.size >= 1000) pieced.clear(); pieced.set(text, split); return split; }</script>
<script lang="ts"> import { onMount, tick, untrack, type Snippet } from 'svelte'; import { busy } from '../live.svelte'; import { boards } from '../boards.svelte'; import { popped } from '../board.svelte'; import { ApiError, copyText, getShellCommand, interruptSession, submitShellCommand, uuidv7, type HistoryEntry, type Session } from '../api'; import type { Usage } from '../context'; import ArtifactView from '../views/ArtifactView.svelte'; import { sessions } from '../sessions.svelte'; import { compose, drafts } from '../notes'; import { draftOf } from './drafts'; import type { Attached } from './attachments'; import { stamp } from '../format'; import LiveFace from '../LiveFace.svelte'; import { face, formatElapsed } from '../moods'; import { Transcript, type Made } from '../transcript.svelte'; import Composer from './Composer.svelte'; import Markdown from './Markdown.svelte'; import Receipt from './Receipt.svelte'; import WorkSteps from './WorkSteps.svelte'; import ShellCommand from './ShellCommand.svelte'; import { pendingShell, resolveShell } from './shellAttempts'; import ReviewView from '../views/ReviewView.svelte'; import TerminalView from '../views/TerminalView.svelte'; import ShellCommandView from '../views/ShellCommandView.svelte'; import ArtifactsButton from '../ArtifactsButton.svelte'; import SkillsButton from '../SkillsButton.svelte'; import JobsButton from '../JobsButton.svelte'; import { headline } from '../jobs.svelte'; import ModelPicker from './ModelPicker.svelte'; import { catalogue, invoke, type Command } from '../commands'; import type { Panel } from './panel';
// On the canvas a chat is a window, whose reviews, artifacts and notes open // beside it. `inline` is the phone's single chat: a review opens inside the // chat, under the line that asked for it, an artifact above the composer, // and there are no notes. `full` is the full-screen chat (FocusChat), inline // too: the model, artifacts, jobs and a terminal sit in the composer, and // reviews, artifacts and terminals go to `onopen`, beside the chat. let { sessionId, session, windowId, inline = false, full = false, handle, chips, onusage, onopen, onback, }: { sessionId: string; session: Session | undefined; windowId: string; inline?: boolean; full?: boolean; handle?: Snippet; /** The window's chips, to sit under the composer instead of in its title bar. */ chips?: Snippet; onusage?: (usage: Usage | null, entries: HistoryEntry[]) => void; onopen?: (panel: Panel) => void; /** The phone's way back to its session list, from the chat's header. */ onback?: () => void; } = $props();
let shown = $state<{ at: string; turnId: string; from: number; jump?: { path: string; at: number } } | null>(null); /** A job's output opened inside the phone's chat, where there is no canvas to put a window on. */ let terminal = $state<{ job: string; command: string } | null>(null); /** Human command output opened in the phone's dock; its process outlives the view. */ let commandPanel = $state<string | null>(null); function openShellCommand(commandId: string) { if (!inline) boards.board?.openShellCommand(sessionId, commandId, windowId); else if (onopen) onopen({ kind: 'command', commandId }); else { terminal = null; artifact = null; commandPanel = commandId; } } /** An artifact opened inside the phone's chat. */ let artifact = $state<Made | null>(null);
function openArtifact(made: Made) { if (onopen) onopen({ kind: 'artifact', ...made }); else if (inline) { commandPanel = null; artifact = made; } else boards.board?.openArtifact(made.id, sessionId, made.version, windowId); }
/** Opens a turn's review: beside the window, or under the line it was asked from. */ function review(at: string, turnId: string, from: number, path?: string) { if (!inline) return boards.board?.openReview(sessionId, turnId, from, windowId, path); const jump = path ? { path, at: Date.now() } : undefined; if (onopen) return onopen({ kind: 'review', turnId, from, jump }); shown = shown?.at === at && shown.turnId === turnId && !path ? null : { at, turnId, from, jump }; }
// svelte-ignore state_referenced_locally const transcript = new Transcript(sessionId); // The stream's phase is fresher than the session list's; the list answers until the stream does. const view = $derived(session && transcript.phase ? { ...session, status: { ...session.status!, phase: transcript.phase } } : session); const running = $derived(busy(view)); $effect(() => { transcript.running = running; }); // Untracked: what the callback reads must not rerun this, or two chats of // one session (a window and the full-screen chat) overwrite each other forever. $effect(() => { const usage = transcript.usage; const entries = transcript.entries; untrack(() => onusage?.(usage, entries)); }); let copied = $state<string | null>(null); /** The failure row opened to all its error said. */ let unfolded = $state<string | null>(null);
async function copy(key: string, text: string) { await copyText(text); copied = key; setTimeout(() => copied === key && (copied = null), 1400); } // The turn whose reply the pointer is over, by the key of its sign-off row, // which shows its actions (trace, copy) for the whole reply and not just // while the row itself is under the pointer. Found in the page when the // pointer moves: a reply's sign-off is the next one after it, before the // next message you sent. let hot = $state<string | null>(null);
function hover(event: PointerEvent) { let row = (event.target as Element).closest('.assistant, [data-end]'); while (row && !row.hasAttribute('data-end') && !row.classList.contains('user')) row = row.nextElementSibling; hot = row?.getAttribute('data-end') ?? null; } const last = $derived(transcript.groups.findLast((group) => group.kind !== 'user' || group.state === 'seen')); const activity = $derived(session?.activity?.lines.findLast((line) => line.text.trim())?.text.trim() ?? '');
let scroller = $state<HTMLElement>(); let thread = $state<HTMLElement>(); let top = $state<HTMLElement>(); let pinned = $state(true); let dockHeight = $state(0); // The end of the chat as it was when the reader scrolled up from it, so a // new message or more of a reply below can be told from older ones loading // above. const end = $derived(`${transcript.groups.at(-1)?.key ?? ''}:${transcript.live.reduce((length, item) => length + item.text.length, 0)}`); let leftAt = $state<string | null>(null); const unseen = $derived(!pinned && leftAt !== null && end !== leftAt); let settled = $state(false); let older = false;
// What the agent makes while the chat is open opens as it arrives: beside // the chat on the canvas, inside it on a phone. let opened = 0; $effect(() => { const made = transcript.made; for (const item of made.slice(opened)) { if (inline) openArtifact(item); else boards.board?.artifactMade(item.id, sessionId, item.version, windowId); } opened = made.length; });
// Full screen: a turn that changed files puts its changes on the desk as // it ends. The turn the chat opened on is only remembered. let ended: string | null = null; $effect(() => { if (!full || !settled) return; const end = transcript.groups.findLast((group) => group.kind === 'end'); if (!end || end.kind !== 'end' || end.key === ended) return; const first = ended === null; ended = end.key; if (!first && end.changed.files && end.turnId) untrack(() => review(end.key, end.turnId!, end.from)); });
onMount(() => { transcript .load() .then(restore) .finally(() => (settled = true)); transcript.attach(); return () => { keep(); transcript.detach(); }; });
function keep() { // Unmounted before it got back to its place, it keeps the one it had. if (!settled) return; places.delete(windowId); if (pinned || !reading) return; places.set(windowId, { block: `[data-block="${CSS.escape(reading.block.getAttribute('data-block')!)}"]`, share: reading.share }); if (places.size > 64) places.delete(places.keys().next().value!); }
/** Back to where the chat was read before it unmounted, reading earlier pages until that block is in; else its end. */ async function restore() { const place = places.get(windowId); if (!place) return toBottom(); let block: Element | null = null; for (let pages = 0; ; pages++) { await tick(); block = thread?.querySelector(place.block) ?? null; if (block || !transcript.more || pages >= 20) break; await transcript.loadOlder(); } if (!block || !scroller) return toBottom(); holdAt(block, place.share); pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 40; if (!pinned) { mark(); leftAt = end; } }
// The stream says when history grows. Polling is the fallback while it is // down, quick while the session works. $effect(() => { const every = transcript.streaming ? 20_000 : running || transcript.pending.length ? 1200 : 5000; let timer: ReturnType<typeof setTimeout>; const tickOnce = async () => { if (!document.hidden && !transcript.loading) await transcript.poll().catch(() => {}); timer = setTimeout(tickOnce, every); }; timer = setTimeout(tickOnce, every); return () => clearTimeout(timer); });
// Earlier pages load one at a time when the reader nears the top, keeping // their place. Watching starts once the first page sits at the bottom. $effect(() => { if (!top || !scroller || !settled) return; const observer = new IntersectionObserver( async ([entry]) => { if (!entry.isIntersecting || !transcript.more || older || !scroller) return; older = true; const before = scroller.scrollHeight; try { await transcript.loadOlder(); await tick(); scroller.scrollTop += scroller.scrollHeight - before; } finally { older = false; } }, { root: scroller, rootMargin: '200px 0px 0px 0px' }, ); observer.observe(top); return () => observer.disconnect(); });
// Stay at the bottom as messages arrive, unless the reader scrolled up. $effect(() => { transcript.groups; activity; transcript.live; if (pinned) toBottom(); });
// A reflow resizes the thread and its text rewraps: a chat at its end stays // there, and one read further up stays as far through the block at its top // as it was (holding that block's top still, as scroll anchoring does, moves // everything after it as the block rewraps). The block is the top one of the // transcript's own (`data-block`), so `places` can find it again. let reading: { block: Element; share: number } | null = null;
function mark() { const blocks = thread?.children; if (!scroller || !blocks?.length) return; const edge = scroller.getBoundingClientRect().top; let low = 0; let high = blocks.length - 1; while (low < high) { const middle = (low + high + 1) >> 1; if (blocks[middle].getBoundingClientRect().top <= edge) low = middle; else high = middle - 1; } let block: Element | null = blocks[low]; while (block && !block.hasAttribute('data-block')) block = block.nextElementSibling; const rect = block?.getBoundingClientRect(); reading = block && rect?.height ? { block, share: (edge - rect.top) / rect.height } : null; }
/** Scrolls the thread so its top edge is `share` of the way through `block`. */ function holdAt(block: Element, share: number) { if (!scroller) return; const rect = block.getBoundingClientRect(); scrollTo(scroller.scrollTop + (rect.top + share * rect.height - scroller.getBoundingClientRect().top) / zoom()); }
$effect(() => { if (!scroller) return; const view = scroller; const observer = new ResizeObserver(() => { if (pinned) return scrollTo(view.scrollHeight); if (reading?.block.isConnected) holdAt(reading.block, reading.share); }); observer.observe(view); return () => observer.disconnect(); });
/** * Screen pixels over the thread's own: the chat's zoom, a window's, a * canvas's. Read off the whole thread, whose rounded height is off by far * less than the scroller's would be over a long jump. */ function zoom() { return thread ? thread.getBoundingClientRect().height / thread.offsetHeight || 1 : 1; }
async function toBottom() { await tick(); if (scroller) scrollTo(scroller.scrollHeight); }
/** * Scrolls the thread for the reader: not their scroll, so it neither unpins * nor moves where they are reading, even when a resize lays the thread out * again before its scroll event arrives. */ function scrollTo(top: number) { if (!scroller) return; autoScrolling = true; scroller.scrollTop = top; requestAnimationFrame(() => (autoScrolling = false)); }
// On a phone the composer floats over the thread, which scrolls on under it; // a draft growing taller must not bury the end of a chat that was there. $effect(() => { if (!inline) return; dockHeight; if (pinned) toBottom(); });
let autoScrolling = false;
function scrolled() { if (!scroller || autoScrolling) return; const was = pinned; pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 40; if (!pinned) mark(); if (was && !pinned) leftAt = end; else if (pinned) leftAt = null; }
/** Back to the end at once. */ function latest() { if (scroller) scroller.scrollTop = scroller.scrollHeight; }
// Notes tied to this chat or any window of its session go with the next // message, after what was typed, unless they are kept alone. const going = $derived(!inline && boards.board ? drafts(boards.board, sessionId).filter((note) => !note.data.alone) : []);
/** Moves the draft into a note beside the window, for writing at length. */ function popout(text: string) { const board = boards.board; const frame = scroller?.closest('.window-slot')?.getBoundingClientRect(); const spot = frame && board?.besideWindow(windowId, frame.bottom - 240); board?.addNote(spot ? { x: spot.x, y: spot.y } : { x: 0, y: 0 }, { kind: 'window', window: windowId }, text, spot?.canvas); }
/** Stops the turn; messages queued behind it were cancelled, so they come back to the composer. */ async function stop() { const cancelled = await interruptSession(sessionId); const sends = transcript.take(cancelled); if (sends.length) draftOf(sessionId).restore(sends); }
function send(text: string, images: Attached[]) { pinned = true; const board = boards.board; if (!board || !going.length) return void transcript.send(text, images); transcript.send(compose(board, going, text), images); for (const note of going) board.updateNote(note.id, { sent: true }); }
// Slash commands: skills and command inputs join the transcript like a // message; reads and changes answer here, above the composer, until dismissed. let commands = $state<Command[]>([]); let answer = $state<{ name: string; text: string; failed: boolean } | null>(null); let refreshCommands = $state<() => void>(() => {}); $effect(() => { const id = sessionId; const controller = new AbortController(); let pending = false; commands = []; // Refresh on opening autocomplete, not on every keystroke or a polling timer. // A slow initial fetch and the first slash share one request. const refresh = () => { if (pending || controller.signal.aborted) return; pending = true; catalogue(id, controller.signal) .then((found) => { if (controller.signal.aborted) return; commands = found; if (answer?.name === 'commands') answer = null; }) .catch((error) => { if (!controller.signal.aborted) answer = { name: 'commands', text: String((error as Error).message ?? error), failed: true }; }) .finally(() => (pending = false)); }; refreshCommands = refresh; refresh(); return () => controller.abort(); });
async function shell(commandText: string): Promise<boolean> { let attempt: ReturnType<typeof pendingShell>; try { attempt = pendingShell(sessionId, commandText); } catch (error) { answer = { name: '!', text: `couldn't save retry identity: ${(error as Error).message ?? error}`, failed: true }; return false; } try { let result; try { result = await getShellCommand(sessionId, attempt.id); } catch (error) { if (!(error instanceof ApiError && error.status === 404)) throw error; result = await submitShellCommand(sessionId, attempt.id, attempt.command); } pinned = true; transcript.trackShell(result); try { resolveShell(attempt); } catch (error) { answer = { name: '!', text: `command was recorded, but its retry marker could not be cleared: ${(error as Error).message ?? error}`, failed: true }; } return true; } catch (error) { answer = { name: '!', text: String((error as Error).message ?? error), failed: true }; return false; } }
async function command(chosen: Command, rest: string) { answer = null; try { const outcome = await invoke(sessionId, chosen, rest, uuidv7()); if (outcome) answer = { name: chosen.slash_name, text: chosen.delivery === 'mutation' ? 'done' : describe(outcome.result), failed: false }; else pinned = true; if (chosen.delivery === 'mutation') sessions.refresh(); } catch (error) { answer = { name: chosen.slash_name, text: String((error as Error).message ?? error), failed: true }; } }
/** A command's answer as text: its message when it has one, else the JSON. */ function describe(result: unknown): string { if (typeof result === 'string') return result; const record = result as Record<string, unknown> | null; for (const key of ['text', 'message', 'summary']) if (typeof record?.[key] === 'string') return record[key] as string; return JSON.stringify(result, null, 2); }</script>
<div class="chat" class:inline class:full style:--dock={inline ? `${dockHeight}px` : undefined}> {#if inline && !full} <header class="bar"> {#if onback} <button class="back" aria-label="back to sessions" onclick={onback}> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg> </button> {/if} <h1>{session?.name || 'untitled session'}</h1> {#if session}<ModelPicker {session} usage={transcript.usage} entries={transcript.entries} down />{/if} <span class="top-tools"> <ArtifactsButton {sessionId} busy={running} onopen={(found) => openArtifact({ id: found.id, version: found.version, title: found.title })} /> <SkillsButton {sessionId} busy={running} /> <JobsButton {sessionId} onopen={(job) => { commandPanel = null; terminal = { job, command: '' }; }} /> </span> </header> {/if} <div class="scroller nowheel" bind:this={scroller} onscroll={scrolled}> {#if !settled && !transcript.error} <!-- Shaped like the end of a typical chat, as measured at phone width: a work line, your message, more work, then the reply's last lines and its sign-off, sitting at the bottom where the chat will open. --> <div class="skeleton" aria-label="loading the chat" role="status"> <span class="bar work"></span> <span class="bubble-shape"></span> <span class="bar work"></span> {#each [92, 100, 84, 96, 70, 88, 100, 60] as width, index (index)}<span class="bar line" style:width="{width}%"></span>{/each} <span class="bar end"></span> </div> {/if} <div class="thread" class:waiting={!settled} bind:this={thread} onpointerover={hover} onpointerleave={() => (hot = null)} role="presentation"> <div bind:this={top}></div> {#if transcript.loading} <span></span> {:else if transcript.error} <p class="quiet error">(。•︿•。) couldn't read the transcript: {transcript.error}</p> {:else if transcript.groups.length === 0} <p class="quiet">(˶ᵔ ᵕ ᵔ˶) nothing here yet. say hi below</p> {:else if transcript.more} <p class="quiet">earlier messages load as you scroll up</p> {/if}
{#each transcript.groups as group (group.key)} {#if group.kind === 'user'} {@const unseen = group.state === 'sending' || group.state === 'sent'} <div class="user" class:failed={group.state === 'failed'} data-entry={group.key} data-block={group.key}> {#if group.from || group.gap} <span class="plate"><span class:you={!group.from}>{group.from ?? 'you'}</span>{#if group.gap}<span class="meta">{' · '}{group.gap}</span>{/if}</span> {/if} {#if group.images.length} <div class="pictures" class:unseen> {#each group.images as picture, index (index)} <a href={picture.src} target="_blank" rel="noopener noreferrer" title="Image #{index + 1}"> <img src={picture.src} alt="Image #{index + 1}" width={picture.width} height={picture.height} loading="lazy" /> </a> {/each} </div> {/if} {#if group.text.trim()} <div class="bubble" class:unseen>{#each pieces(group.text) as piece, index (index)}{#if piece.kind === 'quote'}<blockquote>{piece.text}</blockquote>{:else if piece.kind === 'code'}<pre>{piece.text}</pre>{:else}<p>{piece.text}</p>{/if}{/each}</div> {/if} {#if group.at || group.skill || (transcript.receipt === group.key && group.state !== 'failed')} <span class="user-foot"> {#if group.skill}<span class="skill" title="loaded the {group.skill} skill">skill · {group.skill}</span>{/if} {#if group.at} <time class="stamp" datetime={new Date(group.at).toISOString()} title={new Date(group.at).toLocaleString()}>{stamp(group.at)}</time> {/if} {#if transcript.receipt === group.key && group.state !== 'failed'} <Receipt state={group.state} /> {/if} </span> {/if} {#if group.state === 'failed'} <span class="problem"> not sent: {group.error} <button onclick={() => transcript.retry(group.key.slice('pending:'.length))}>retry</button> <button onclick={() => transcript.discard(group.key.slice('pending:'.length))}>discard</button> </span> {/if} </div> {:else if group.kind === 'assistant'} <div class="assistant" data-entry={group.key} data-block={group.key}> {#if group.plate}<span class="plate agent">albedo</span>{/if} <Markdown text={group.text} /> </div> {:else if group.kind === 'shell'} <ShellCommand sessionId={sessionId} command={group.command} onopen={() => openShellCommand(group.command.id)} openLabel={inline ? 'Open command output' : 'Open command on canvas'} /> {:else if group.kind === 'work'} <WorkSteps block={group.key} steps={group.steps} live={running && (group === last || group.steps.some((step) => step.kind === 'progress' || step.key.startsWith('live:') || (step.kind === 'tool' && !step.done)))} {transcript} session={view} onreview={(turnId, from, path) => review(group.key, turnId, from, path)} onartifact={openArtifact} /> {:else if group.kind === 'end'} {@const metaText = [group.elapsedMs >= 1000 ? formatElapsed(group.elapsedMs) : '', group.tools ? `${group.tools} ${group.tools === 1 ? 'tool' : 'tools'}` : ''].filter(Boolean).join(' · ')} {@const hasReview = !!(group.changed.files && group.turnId)} <p class="end" class:hot={hot === group.key} data-end={group.key} data-block={group.key}> <span class="face kaomoji" title={group.mood}>{face(group.mood, group.seed)}</span> {#if metaText} <span class="meta">{metaText}</span> {/if} {#if hasReview} <button class="review" title="review this turn's changes" aria-expanded={inline ? shown?.at === group.key : undefined} onclick={() => review(group.key, group.turnId!, group.from)}> review {group.changed.files} {group.changed.files === 1 ? 'file' : 'files'} {#if group.changed.added || group.changed.removed}<span class="added">+{group.changed.added}</span> <span class="removed">−{group.changed.removed}</span>{/if} </button> {/if} {#if group.seed} <time class="stamp" datetime={new Date(group.seed).toISOString()} title={new Date(group.seed).toLocaleString()}>{metaText && !hasReview ? `· ${stamp(group.seed)}` : stamp(group.seed)}</time> {/if} {#if group.turnId && group.tools && !inline} <button class="act" title="trace this turn and its agents on a timeline" aria-label="trace" onclick={() => boards.board?.openTrace(sessionId, group.from, windowId)}> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M2 4h6M5 8h8M3 12h5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg> </button> {/if} {#if group.text} <button class="act" title={copied === group.key ? 'copied' : 'copy this reply'} aria-label={copied === group.key ? 'copied' : 'copy'} onclick={() => copy(group.key, group.text)}> {#if copied === group.key} <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" /></svg> {:else} <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="2" fill="none" stroke="currentColor" stroke-width="1.5" /><path d="M10.5 3.5v-.2a1.8 1.8 0 0 0-1.8-1.8H3.8A1.8 1.8 0 0 0 2 3.3v4.9a1.8 1.8 0 0 0 1.8 1.8h.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg> {/if} </button> {/if} </p> {:else if group.kind === 'note'} <p class="note" data-block={group.key}>{group.text}</p> {:else if group.kind === 'failure'} {@const open = unfolded === group.key} <button class="failure" class:open data-block={group.key} aria-expanded={open} title={open ? undefined : group.detail} onclick={() => (unfolded = open ? null : group.key)}> <span class="kaomoji">(。•︿•。)</span> <span class="why">this turn failed: {group.text}{#if open && group.detail !== group.text}<code>{group.detail}</code>{/if}</span> </button> {:else if group.kind === 'compaction'} <p class="note" data-block={group.key}>{'(>_<)'} earlier context was compacted here</p> {/if} {#if shown?.at === group.key} <section class="inline-review" aria-label="changes"> <header> <span>changes</span> <button onclick={() => (shown = null)} aria-label="close the changes">close</button> </header> <ReviewView sessionId={sessionId} turnId={shown.turnId} from={shown.from} workspace={session?.workspace ?? ''} jump={shown.jump} keyOf={(path, change) => `${windowId}#${path}#${change}`} onfiles={() => pinned && toBottom()} /> </section> {/if} {/each}
{#if running} <p class="live"><LiveFace session={view} label />{#if !transcript.streaming && activity}<span class="activity">{activity}</span>{/if}</p> {/if} </div> </div>
<div class="dock" bind:offsetHeight={dockHeight}> {#if settled && !pinned} <button class="latest" onclick={latest}> <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg> {unseen ? 'see new messages' : 'back to latest'} </button> {/if} {@render handle?.()} {#if answer} <section class="answer" class:failed={answer.failed} aria-live="polite"> <header><span>{answer.name}</span><button aria-label="dismiss" onclick={() => (answer = null)}>×</button></header> <pre class="nowheel holds-wheel">{answer.text}</pre> </section> {/if} {#if inline} {#if artifact} <section class="inline-artifact" aria-label="artifact"> <header> <span title={artifact.title}>{artifact.title}</span> <button onclick={() => (artifact = null)} aria-label="close the artifact">close</button> </header> {#key artifact.id} <ArtifactView artifactId={artifact.id} sessionId={sessionId} latest={artifact.version} inline /> {/key} </section> {/if} {#if commandPanel} <section class="inline-terminal" aria-label="Shell command"> <header><span>shell command output</span><button onclick={() => (commandPanel = null)} aria-label="Close command output">close</button></header> {#key commandPanel}<ShellCommandView {sessionId} commandId={commandPanel} />{/key} </section> {/if} {#if terminal} <section class="inline-terminal" aria-label="terminal"> <header> <span title={terminal.command}>{terminal.command ? headline(terminal.command) : 'terminal'}</span> <button onclick={() => (terminal = null)} aria-label="close the terminal">close</button> </header> {#key terminal.job} <TerminalView {sessionId} jobId={terminal.job} onjob={(found) => terminal && (terminal.command = found.command)} /> {/key} </section> {/if} {/if} <Composer tools={full ? tools : chips} draft={draftOf(sessionId)} onsend={send} busy={running} {commands} oncommands={refreshCommands} oncommand={command} onshell={shell} compact={inline} onpopout={inline || popped ? undefined : popout} onstop={stop} attached={going.length} placeholder={going.length ? 'add a message, or just send the notes…' : undefined} /> </div></div>
{#snippet tools()} {#if session}<ModelPicker {session} usage={transcript.usage} entries={transcript.entries} />{/if} <ArtifactsButton {sessionId} busy={running} up onopen={(found) => openArtifact({ id: found.id, version: found.version, title: found.title })} /> <JobsButton {sessionId} up onopen={(job) => onopen?.({ kind: 'job', job })} /> <button type="button" class="shell" aria-label="open a terminal" title="open a terminal{session ? ` in ${session.workspace}` : ''}" onclick={() => onopen?.({ kind: 'terminal' })}> <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path d="M3 4.5l3.5 3.5L3 11.5M8 12h5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg> </button>{/snippet}
<style> /* The chat reads a size up from the rest of the canvas; everything in it, text and the spacing around it, scales together. */ .chat { display: flex; flex: 1; flex-direction: column; min-height: 0; zoom: 1.15; } /* On a phone the chat is the whole screen: no scaling, the reading size set outright, and the room at the sides given back to the text. */ .chat.inline { position: relative; zoom: 1; } /* The thread ends clear of the floating composer and handle. */ .inline .thread { gap: 12px; padding: 14px 16px calc(var(--dock, 0px) + 16px); font-size: calc(16px * var(--canvas-scale)); line-height: 1.55; } /* Nothing floats over the top of the full-screen chat; the thread starts near it. */ .full .thread { padding-top: 28px; } .full.inline .latest { bottom: calc(100% + 10px); } /* As the menu icons beside it (MenuButton's .icon). */ .shell { display: inline-flex; flex: none; align-items: center; height: 26px; padding: 0 7px; color: var(--muted); background: var(--well); border: 0; border-radius: 8px; cursor: pointer; transition: background-color 120ms ease, color 120ms ease; } .shell:hover { color: var(--text); background: var(--field); } /* The phone's chat says which session it is and leads back to the list. */ .bar { position: relative; z-index: 10; display: flex; flex: none; align-items: center; gap: 6px; height: 46px; padding: 0 10px 0 4px; box-shadow: 0 1px 0 color-mix(in oklab, var(--text) 7%, transparent); } .bar h1 { flex: 1; min-width: 0; margin: 0 0 0 6px; overflow: hidden; font-size: calc(15px * var(--ui-scale)); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } .back + h1 { margin-left: 0; } .back { display: grid; flex: none; place-items: center; width: 36px; height: 36px; padding: 0; color: var(--muted); background: none; border: 0; border-radius: 10px; cursor: pointer; } .back:active { background: var(--hover); } .top-tools { display: flex; flex: none; align-items: center; gap: 5px; } .inline .dock { position: absolute; right: 0; bottom: 0; left: 0; padding: 0 10px calc(8px * (1 - var(--keyboard, 0))); } /* A band of the chat's ground under the handle and the composer, so the thread fades out behind them instead of showing between them. */ .inline:not(.full) .dock { background: linear-gradient(transparent, var(--surface) 16px); } /* Floats over the end of the thread while it is scrolled up, in the composer's tone; on a phone, above the drawer's handle. */ .latest { position: absolute; bottom: calc(100% + 8px); left: 50%; z-index: 2; display: flex; align-items: center; gap: 6px; padding: 6px 13px 6px 10px; color: var(--text); font: inherit; font-size: calc(12.5px * var(--canvas-scale)); white-space: nowrap; background: var(--field); border: 0; border-radius: 999px; box-shadow: 0 1px 2px rgb(0 0 0 / 0.1); cursor: pointer; transform: translateX(-50%); } .latest:hover { background: color-mix(in oklab, var(--field), var(--text) 6%); } .latest svg { color: var(--accent); } /* Until the first page is in and scrolled to its end, the thread stays unseen and the skeleton holds its place, so it never shows its top and then jumps. */ .thread.waiting { visibility: hidden; } .skeleton { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 12px; max-width: 720px; margin: 0 auto; padding: 18px 20px 24px; overflow: hidden; pointer-events: none; } .inline .skeleton { padding: 12px 16px calc(var(--dock, 0px) + 32px); } .skeleton > * { flex: none; border-radius: 8px; background: var(--well); animation: pulse 1.6s ease-in-out infinite; } .skeleton .work { width: 58%; height: 14px; margin: 4px 0; } .skeleton .bubble-shape { align-self: flex-end; width: 64%; height: 72px; border-radius: 16px 16px 4px 16px; background: var(--bubble); } .skeleton .line { height: 14px; margin: 0; } .skeleton .line + .line { margin-top: -2px; } .skeleton .end { width: 30%; height: 12px; } @keyframes pulse { 50% { opacity: 0.55; } } @media (prefers-reduced-motion: reduce) { .skeleton > * { animation: none; } } .scroller { position: relative; isolation: isolate; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } .thread { display: flex; flex-direction: column; gap: 14px; max-width: 720px; margin: 0 auto; padding: 18px 20px 24px; font-size: calc(14px * var(--canvas-scale)); line-height: 1.6; } .quiet { margin: 0; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); text-align: center; } .error { color: var(--danger); } .user { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; margin-top: 6px; } .plate { color: var(--muted); font-size: calc(12px * var(--canvas-scale)); font-weight: 560; } .plate .meta { font-weight: 400; } .user-foot { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: calc(11.5px * var(--canvas-scale)); } .user-foot .skill { color: var(--muted); white-space: nowrap; } .stamp { color: var(--faint); font-variant-numeric: tabular-nums; white-space: nowrap; } .you { color: var(--you); } .agent { display: block; margin-bottom: 2px; color: var(--agent); } .end { display: flex; align-items: baseline; gap: 8px; margin: -4px 0 4px; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); } .end .face { color: var(--agent); font-size: calc(13px * var(--canvas-scale)); } .review { padding: 1px 8px; color: var(--text); font: inherit; background: var(--well); border: 0; border-radius: 6px; cursor: pointer; transition: background-color 120ms ease; } .review:hover { background: var(--selected); } .review .added { color: var(--added); } .review .removed { color: var(--danger); } .act { display: grid; place-items: center; align-self: center; width: 22px; height: 20px; padding: 0; color: var(--muted); background: none; border: 0; border-radius: 5px; cursor: pointer; opacity: 0; transition: opacity 120ms ease, color 120ms ease, background-color 120ms ease; } .end.hot .act, .end:hover .act, .end:focus-within .act, .act:focus-visible { opacity: 1; } .act:hover { color: var(--text); background: var(--hover); } /* No pointer to hover with: the actions stay, quietly. */ @media (hover: none) { .act { opacity: 0.6; } } .inline-review { margin: 4px 0 14px; padding: 10px 12px 14px; background: var(--well); border-radius: 12px; --review-ground: var(--well); --review-sticky-top: -1px; } .inline-review header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); } .inline-review header button { padding: 2px 8px; color: var(--muted); font: inherit; background: none; border: 0; border-radius: 6px; cursor: pointer; } .inline-review header button:hover { color: var(--text); background: var(--hover); } .inline-terminal, .inline-artifact { display: flex; flex-direction: column; height: min(45vh, 360px); margin: 0 0 8px; overflow: hidden; background: var(--well); border-radius: 12px; } /* An artifact takes the height of its page, up to most of the screen. */ .inline-artifact { height: auto; max-height: 70vh; } .inline-terminal header, .inline-artifact header { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px 6px 12px; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); background: var(--title-bar); } .inline-terminal header span, .inline-artifact header span { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .inline-terminal header span { font-family: var(--mono); } .inline-terminal header button, .inline-artifact header button { padding: 2px 8px; color: var(--muted); font: inherit; background: none; border: 0; border-radius: 6px; cursor: pointer; } .inline-terminal header button:hover, .inline-artifact header button:hover { color: var(--text); background: var(--hover); } .bubble { max-width: 85%; padding: 8px 13px; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bubble); border-radius: 16px 16px 4px 16px; } .bubble > * { margin: 0; } .bubble > * + * { margin-top: 8px; } .bubble blockquote { padding-left: 10px; color: var(--muted); border-left: 2px solid color-mix(in oklab, var(--accent) 45%, transparent); } .bubble pre { overflow-x: auto; font-family: var(--mono); font-size: 0.86em; white-space: pre; } .bubble.unseen, .pictures.unseen { opacity: 0.6; } .pictures { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: 85%; } .pictures a { display: block; line-height: 0; } .pictures img { width: auto; max-width: 220px; height: auto; max-height: 160px; object-fit: cover; border-radius: 12px; } .failed .bubble { box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--danger) 50%, transparent); } .problem { color: var(--danger); font-size: calc(11.5px * var(--canvas-scale)); } .problem button { padding: 0 2px; color: var(--text); font: inherit; text-decoration: underline; background: none; border: 0; cursor: pointer; } .note { margin: 0; color: var(--muted); font-size: calc(12px * var(--canvas-scale)); white-space: pre-wrap; overflow-wrap: anywhere; } .failure { display: flex; gap: 8px; align-items: baseline; min-width: 0; margin: -4px 0 4px; padding: 0; color: var(--muted); font: inherit; font-size: calc(12px * var(--canvas-scale)); text-align: left; background: none; border: 0; cursor: pointer; } .failure .kaomoji { flex: none; color: var(--danger); } .failure .why { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .failure.open .why { white-space: pre-wrap; overflow-wrap: anywhere; } .failure code { display: block; margin-top: 4px; color: var(--faint); font-family: var(--mono); font-size: calc(11px * var(--canvas-scale)); } .live { display: flex; gap: 8px; min-width: 0; margin: 0; font-size: calc(12.5px * var(--canvas-scale)); } .activity { min-width: 0; overflow: hidden; color: var(--muted); font-family: var(--mono); font-size: calc(11.5px * var(--canvas-scale)); white-space: nowrap; text-overflow: ellipsis; } .answer { margin-bottom: 8px; padding: 8px 12px 10px; background: var(--well); border-radius: 12px; } .answer header { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-family: var(--mono); font-size: calc(12px * var(--canvas-scale)); } .answer header button { padding: 0 4px; color: var(--faint); font-size: calc(16px * var(--canvas-scale)); background: none; border: 0; cursor: pointer; } .answer pre { max-height: 220px; margin: 6px 0 0; overflow: auto; overscroll-behavior: contain; font-family: var(--mono); font-size: calc(12px * var(--canvas-scale)); white-space: pre-wrap; overflow-wrap: anywhere; } .answer.failed pre { color: var(--danger); } .dock { position: relative; display: flex; flex-direction: column; flex: none; width: 100%; max-width: 720px; margin: 0 auto; padding: 0 14px 14px; }</style>