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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226<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 { copyText, interruptSession, 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 ReviewView from '../views/ReviewView.svelte'; import TerminalView from '../views/TerminalView.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); /** 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) 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 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) => (artifact = { id: found.id, version: found.version, title: found.title })} /> <SkillsButton {sessionId} busy={running} /> <JobsButton {sessionId} onopen={(job) => (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 === '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 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} 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 { 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 8px; } /* 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; 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>