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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532<script lang="ts"> import { uuidv7 } from './api'; import Chat from './chat/Chat.svelte'; import type { Panel } from './chat/panel'; import LiveFace from './LiveFace.svelte'; import { homePath, place } from './format'; import { headline } from './jobs.svelte'; import { shortcut, typing } from './keys'; import { layout } from './layout.svelte'; import { busy } from './live.svelte'; import { prefs } from './prefs.svelte'; import { sessions } from './sessions.svelte'; import { deleteTerminal } from './terminals.svelte'; import ArtifactView from './views/ArtifactView.svelte'; import PtyView from './views/PtyView.svelte'; import ReviewView from './views/ReviewView.svelte'; import TerminalView from './views/TerminalView.svelte'; import ShellCommandView from './views/ShellCommandView.svelte';
// A session opened full-screen from the rail: zen mode. The chat is the one // thing fully lit; the header and the composer's chips rest dim until // reached for. What the work makes (a page, a turn's changes, a job's // output, a shell) lands on a desk beside it (right of the chat unless // `prefs.deskLeft`), kept as quiet tabs for the sitting. Escape only hides: the desk, then the chat; a tab goes by its ×. let { sessionId }: { sessionId: string } = $props(); const session = $derived(sessions.byId.get(sessionId));
const SHARE_KEY = 'albedo.focus.desk'; const SHARE = { least: 0.28, most: 0.68, start: 0.5 }; const TABS = 6; let share = $state(Number(localStorage.getItem(SHARE_KEY)) || SHARE.start); let tabs = $state<Panel[]>([]); let current = $state<string | null>(null); let shown = $state(true); let shell = $state<string | null>(null); let commands = $state<Record<string, string>>({}); let root = $state<HTMLElement>(); let body = $state<HTMLElement>(); let dragging = $state(false); const panel = $derived(tabs.find((tab) => keyOf(tab) === current) ?? null); const right = $derived(!prefs.deskLeft);
$effect(() => () => sessions.clearReading(sessionId)); $effect(() => { const id = shell; return () => { if (id) deleteTerminal(id); }; });
function keyOf(tab: Panel): string { if (tab.kind === 'artifact') return `artifact:${tab.id}`; if (tab.kind === 'job') return `job:${tab.job}`; if (tab.kind === 'command') return `command:${tab.commandId}`; if (tab.kind === 'review') return `review:${tab.turnId}`; return 'terminal'; }
/** Puts a tab on the desk (or brings its tab forward, refreshed) and shows it. */ function open(next: Panel) { const key = keyOf(next); if (next.kind === 'terminal') shell ??= uuidv7(); const at = tabs.findIndex((tab) => keyOf(tab) === key); if (at >= 0) tabs[at] = next; else { tabs.push(next); // The oldest tab gives way, never the shell. if (tabs.length > TABS) tabs.splice(tabs.findIndex((tab) => tab.kind !== 'terminal'), 1); } current = key; shown = true; }
function close(tab: Panel) { const key = keyOf(tab); if (tab.kind === 'terminal') shell = null; const at = tabs.findIndex((other) => keyOf(other) === key); tabs.splice(at, 1); const next = tabs[Math.min(at, tabs.length - 1)]; if (current === key) current = next ? keyOf(next) : null; }
function label(tab: Panel): string { if (tab.kind === 'artifact') return tab.title; if (tab.kind === 'job') return commands[tab.job] ? headline(commands[tab.job]) : 'job output'; if (tab.kind === 'command') return commands[tab.commandId] ? headline(commands[tab.commandId]) : 'shell command'; if (tab.kind === 'review') return 'changes'; return session ? place(session.workspace) : 'shell'; }
function grab(event: PointerEvent) { if (event.button !== 0 || !body) return; const handle = event.currentTarget as HTMLElement; handle.setPointerCapture(event.pointerId); dragging = true; const box = body.getBoundingClientRect(); const move = (moved: PointerEvent) => (share = clamp((right ? box.right - moved.clientX : moved.clientX - box.left) / box.width)); const done = () => { dragging = false; localStorage.setItem(SHARE_KEY, String(share)); handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', done); handle.removeEventListener('pointercancel', done); }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', done); handle.addEventListener('pointercancel', done); }
function nudge(event: KeyboardEvent) { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); // The arrow pointing away from the desk widens it. const wider = (event.key === 'ArrowRight') !== right; share = clamp(share + (wider ? 0.04 : -0.04)); localStorage.setItem(SHARE_KEY, String(share)); }
function clamp(value: number) { return Math.min(SHARE.most, Math.max(SHARE.least, value)); }
function keydown(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key === '\\') { event.preventDefault(); if (tabs.length) shown = !shown; return; } // A field, an open menu and a pane over the chat keep their Escape. if (event.key !== 'Escape' || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || typing(event)) return; if (root?.querySelector('.menu.open, [role="dialog"]')) return; event.preventDefault(); if (panel && shown) shown = false; else layout.unfocus(); }</script>
<svelte:window onkeydown={keydown} />
{#snippet glyph(kind: Panel['kind'])} <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"> {#if kind === 'artifact'} <rect x="2" y="3" width="12" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.4" /><path d="M2 6h12" stroke="currentColor" stroke-width="1.4" /> {:else if kind === 'review'} <path d="M5 3v10M3 5h4M11 9v4M9 11h4M9 4.5h4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" /> {:else if kind === 'job'} <path d="M1.5 8.5h2.8l1.9-4.5 3.2 8 1.9-4.5h3.2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" /> {:else} <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" /> {/if} </svg>{/snippet}
<div class="focus" class:dragging class:right bind:this={root} role="dialog" aria-modal="true" aria-label={session?.name || 'session'}> <header> <button class="icon" aria-label="back to the board" title="back to the board (Esc)" onclick={() => layout.unfocus()}> <svg viewBox="0 0 16 16" width="15" height="15" 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> <h1>{session?.name || 'untitled session'}</h1> {#if session}<span class="where" title={homePath(session.workspace)}>{place(session.workspace)}</span>{/if} <span class="spacer"></span> {#if session && busy(session)}<span class="face" aria-label="working"><LiveFace {session} /></span>{/if} {#if tabs.length} <button class="icon desk-toggle" class:on={shown} aria-pressed={shown} aria-label={shown ? 'hide the desk' : 'show the desk'} title="{shown ? 'hide' : 'show'} the desk ({shortcut('\\')})" onclick={() => (shown = !shown)}> <svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.3" /><path d="M7 3v10" stroke="currentColor" stroke-width="1.3" /></svg> <span class="count">{tabs.length}</span> </button> {/if} </header>
<div class="body" bind:this={body} style:--share={share}> {#if panel} <aside class="desk" class:hidden={!shown} aria-label="desk"> <div class="tabs" role="tablist" aria-label="on the desk"> {#each tabs as tab (keyOf(tab))} {@const key = keyOf(tab)} <span class="tab" class:active={key === current}> <button role="tab" aria-selected={key === current} title={label(tab)} onclick={() => (current = key)}> {@render glyph(tab.kind)} <span class="name" class:mono={tab.kind === 'job'}>{label(tab)}</span> </button> <button class="drop" aria-label="close {label(tab)}" title="close" onclick={() => close(tab)}> <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"><path d="m4.5 4.5 7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" /></svg> </button> </span> {/each} </div> <div class="view"> {#if panel.kind === 'artifact'} {#key panel.id}<ArtifactView artifactId={panel.id} {sessionId} latest={panel.version} fill />{/key} {:else if panel.kind === 'job'} {@const job = panel.job} {#key job}<TerminalView {sessionId} jobId={job} onjob={(found) => (commands[job] = found.command)} />{/key} {:else if panel.kind === 'command'} {@const id = panel.commandId} {#key id}<ShellCommandView {sessionId} commandId={id} oncommand={(found) => (commands[id] = found.command)} />{/key} {:else if panel.kind === 'review'} <div class="review"> {#key panel.turnId} <ReviewView {sessionId} turnId={panel.turnId} from={panel.from} workspace={session?.workspace ?? ''} jump={panel.jump} keyOf={(path, change) => `focus:${sessionId}#${path}#${change}`} /> {/key} </div> {/if} {#if shell} <div class="shell" class:hidden={panel.kind !== 'terminal'}> <PtyView terminalId={shell} cwd={session?.workspace ?? ''} {sessionId} /> </div> {/if} </div> </aside> {#if shown} <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions (a focusable separator is the ARIA window splitter) --> <div class="splitter" role="separator" aria-orientation="vertical" aria-label="resize the desk" aria-valuemin={SHARE.least * 100} aria-valuemax={SHARE.most * 100} aria-valuenow={Math.round(share * 100)} tabindex="0" onpointerdown={grab} onkeydown={nudge} ondblclick={() => ((share = SHARE.start), localStorage.setItem(SHARE_KEY, String(share)))} ></div> {/if} {/if} <main> {#if session} <Chat {sessionId} {session} windowId="focus:{sessionId}" inline full onopen={open} onusage={(next, items) => sessions.recordReading(sessionId, next, items)} /> {:else} <p class="gone">(・_・;) this session is no longer on the daemon</p> {/if} </main> </div></div>
<style> /* One quiet surface for the whole window. */ .focus { position: fixed; inset: 0; z-index: var(--z-zen); display: flex; flex-direction: column; background: var(--surface); animation: fade-in 220ms cubic-bezier(0.23, 1, 0.32, 1); } .focus.dragging { cursor: col-resize; user-select: none; } .focus.dragging :global(iframe) { pointer-events: none; } @keyframes fade-in { from { opacity: 0; } } /* The header rests dim and comes up when the pointer or focus reaches it. */ .focus > header { display: flex; flex: none; align-items: center; gap: 8px; height: 46px; padding: 0 10px 0 8px; opacity: 0.5; transition: opacity 260ms ease; } .focus > header:hover, .focus > header:focus-within { opacity: 1; transition-duration: 120ms; } .icon { display: inline-flex; flex: none; align-items: center; justify-content: center; gap: 5px; min-width: 30px; height: 30px; padding: 0 6px; color: var(--muted); font: inherit; font-size: calc(11.5px * var(--ui-scale)); background: none; border: 0; border-radius: 8px; cursor: pointer; transition: background-color 120ms ease, color 120ms ease; } .icon:hover { color: var(--text); background: var(--hover); } .icon:active { transform: scale(0.94); } .desk-toggle.on { color: var(--text); } .count { font-variant-numeric: tabular-nums; } h1 { min-width: 0; margin: 0; overflow: hidden; color: var(--text); font-size: calc(13.5px * var(--ui-scale)); font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } .where { flex: none; color: var(--faint); font-size: calc(12px * var(--ui-scale)); } .spacer { flex: 1; } .face { display: flex; flex: none; margin-right: 4px; color: var(--muted); } .body { display: flex; flex: 1; min-height: 0; } /* On the right, the desk comes after the chat. */ .right .body { flex-direction: row-reverse; } .right .desk-toggle svg { transform: scaleX(-1); } /* The desk: a sheet set into the surface, inset from the window's edges. */ .desk { display: flex; flex: none; flex-direction: column; width: calc(var(--share) * 100%); min-width: 320px; margin: 0 0 12px 12px; overflow: hidden; background: var(--well); border-radius: 14px; box-shadow: 0 0 0 1px color-mix(in oklab, var(--text) 7%, transparent); animation: desk-in 240ms cubic-bezier(0.23, 1, 0.32, 1); } /* Hidden, not gone: the shell and the open page live on under it. */ .desk.hidden { display: none; } .right .desk { margin: 0 12px 12px 0; --from: 12px; } @keyframes desk-in { from { opacity: 0; transform: translateX(var(--from, -12px)); } } .tabs { display: flex; flex: none; gap: 2px; padding: 6px 6px 0; overflow-x: auto; scrollbar-width: none; } .tab { position: relative; display: flex; flex: 0 1 auto; align-items: center; min-width: 0; max-width: 220px; color: var(--muted); border-radius: 9px 9px 0 0; transition: color 120ms ease, background-color 120ms ease; } .tab:hover { color: var(--text); } .tab.active { color: var(--text); background: var(--title-bar); } .tab > button[role='tab'] { display: flex; align-items: center; gap: 7px; min-width: 0; height: 32px; padding: 0 4px 0 11px; color: inherit; font: inherit; font-size: calc(12px * var(--ui-scale)); background: none; border: 0; cursor: pointer; } .tab svg { flex: none; opacity: 0.8; } .name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .name.mono { font-family: var(--mono); font-size: calc(11.5px * var(--ui-scale)); } .drop { display: grid; flex: none; place-items: center; width: 20px; height: 20px; margin-right: 6px; padding: 0; color: var(--faint); background: none; border: 0; border-radius: 6px; cursor: pointer; opacity: 0; transition: opacity 120ms ease; } .tab:hover .drop, .tab.active .drop, .drop:focus-visible { opacity: 1; } .drop:hover { color: var(--text); background: var(--hover); } .view { position: relative; display: flex; flex: 1; flex-direction: column; min-height: 0; background: var(--title-bar); } .review { flex: 1; min-height: 0; padding: 12px 14px 16px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; --review-ground: var(--title-bar); --review-sticky-top: -12px; } .shell { display: flex; flex: 1; flex-direction: column; min-height: 0; } .shell.hidden { display: none; } /* The gap between desk and chat is the handle; a hairline of the accent shows it while pointed at or moving. */ .splitter { position: relative; flex: none; width: 12px; margin-bottom: 12px; cursor: col-resize; touch-action: none; } .splitter::after { position: absolute; top: 30%; bottom: 30%; left: 5px; width: 2px; background: var(--accent); border-radius: 1px; opacity: 0; content: ''; transition: opacity 120ms ease; } .splitter:hover::after, .splitter:focus-visible::after, .dragging .splitter::after { opacity: 0.55; } .splitter:focus-visible { outline: none; } main { display: flex; flex: 1; flex-direction: column; min-width: 360px; min-height: 0; } /* Beside the desk the chat keeps a little more air at the window's edge. */ .desk ~ main { padding-right: 8px; } .right .desk ~ main { padding: 0 0 0 8px; } .gone { margin: 20px; color: var(--muted); } @media (prefers-reduced-motion: reduce) { .focus, .desk { animation: none; } }</style>