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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366<script lang="ts"> import { MiniMap, NodeResizer, SvelteFlow, type NodeTypes } from '@xyflow/svelte'; import { ZOOM, type Board } from './board.svelte'; import { prefs } from './prefs.svelte'; import Dots from './Dots.svelte'; import FlowBinder from './FlowBinder.svelte'; import { shortcut } from './keys'; import NoteNode from './nodes/NoteNode.svelte'; import ReviewNode from './nodes/ReviewNode.svelte'; import TerminalNode from './nodes/TerminalNode.svelte'; import TraceNode from './nodes/TraceNode.svelte'; import AgentsNode from './nodes/AgentsNode.svelte'; import Tethers from './Tethers.svelte'; import { pixels, zoomFactor } from './wheel'; import TraceTie from './TraceTie.svelte'; import SessionNode from './nodes/SessionNode.svelte'; import ArtifactNode from './nodes/ArtifactNode.svelte'; import ChipNode from './nodes/ChipNode.svelte'; import PtyNode from './nodes/PtyNode.svelte';
// Mounted again on every tab switch (keyed on the board), so each board // opens on its own saved view. // // The wheel is handled here rather than by Svelte Flow, because a window's // contents and the canvas share the trackpad: a pinch zooms the canvas // anywhere but over a readable timeline that zooms and pans itself (a trace), an // up-down scroll over a readable window scrolls that window, a sideways one // over something that scrolls sideways (a diff's code, a wide table) // scrolls that, a menu floating over the canvas, or a view that pans and // zooms itself, keeps every scroll over it, and every other scroll pans the // canvas freely. Holding space drags the canvas from anywhere, over windows // too; the middle button drags it from the empty canvas, and over a card it // stays the browser's middle click; holding alt (option) drags a window by any // part of it, not only its title bar. let { board, onpalette, onnew, minimap, mapWidth }: { board: Board; onpalette: () => void; onnew: () => void; minimap: boolean; mapWidth: number } = $props();
const nodeTypes: NodeTypes = { session: SessionNode, review: ReviewNode, terminal: TerminalNode, trace: TraceNode, agents: AgentsNode, artifact: ArtifactNode, chip: ChipNode, note: NoteNode, pty: PtyNode };
// A double-click on empty canvas leaves a note there. Holding space, a // double-click anywhere, over windows too, takes the view there instead. const JUMP_ZOOM = 0.9; function dblclick(event: MouseEvent) { const at = { x: event.clientX, y: event.clientY }; if (hand) { event.stopPropagation(); board.centreOn(at, JUMP_ZOOM); } else if ((event.target as HTMLElement).classList.contains('svelte-flow__pane')) board.noteAt(at); } const READABLE = 0.6; let element = $state<HTMLElement>();
function readableAt(under: Element | null): boolean { if (board.viewport.zoom >= READABLE) return true; const id = under?.closest<HTMLElement>('.svelte-flow__node')?.dataset.id; return !!id && board.viewport.zoom * board.windowZoom(id) >= READABLE; }
/** What an up-down scroll over `under` moves: the innermost box with room that way, else the window's own scroller. */ function scrollerAt(under: HTMLElement | null, dy: number): HTMLElement | null { if (!readableAt(under)) return null; let fallback: HTMLElement | null = null; for (let box = under; box && !box.classList.contains('svelte-flow__node'); box = box.parentElement) { if (box.scrollHeight <= box.clientHeight + 1) continue; const overflow = getComputedStyle(box).overflowY; if (overflow !== 'auto' && overflow !== 'scroll') continue; if (dy < 0 ? box.scrollTop > 0 : box.scrollTop + box.clientHeight < box.scrollHeight - 1) return box; if (box.classList.contains('nowheel')) fallback ??= box; } return fallback; }
// The path crosses into shadow roots, so Pierre's code element counts. function scrollsSideways(event: WheelEvent, under: Element | null) { if (!readableAt(under)) return false; for (const target of event.composedPath()) { if (!(target instanceof HTMLElement) || target.classList.contains('svelte-flow__node')) break; const overflow = getComputedStyle(target).overflowX; if ((overflow === 'auto' || overflow === 'scroll') && target.scrollWidth > target.clientWidth + 1) return true; } return false; }
let hand = $state(false); let panning = $state<{ x: number; y: number } | null>(null); let alt = $state(false); let carrying = $state<{ id: string; pointer: { x: number; y: number }; from: { x: number; y: number } } | null>(null); // The click that ends a carry is swallowed, so letting go over a link or a // button presses nothing (option-click would download a link). let swallow = false;
function typing(target: EventTarget | null) { return !!(target as HTMLElement | null)?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'); }
function keydown(event: KeyboardEvent) { if (event.key === 'Alt') alt = true; if (event.key !== ' ' || typing(event.target) || event.metaKey || event.ctrlKey || event.altKey) return; // Held, not typed: no scrolling the page or pressing the focused button. event.preventDefault(); hand = true; }
function keyup(event: KeyboardEvent) { if (event.key === ' ') hand = false; if (event.key === 'Alt') alt = false; }
function grab(event: PointerEvent) { const hit = event.button === 0 ? (event.target as Element | null)?.closest<HTMLElement>('.svelte-flow__node') : null; if (hit?.dataset.id) board.select(hit.dataset.id); const frame = hit && event.altKey && !hand ? hit : null; const node = frame && board.nodes.find((candidate) => candidate.id === frame.dataset.id); // The middle button over a card belongs to the card (its links, the // browser's autoscroll), so only the empty canvas pans from it. if (event.button === 1 && (event.target as Element).closest('.svelte-flow__node')) return; if (!(event.button === 1 || (hand && event.button === 0) || node)) return; // Taken before Svelte Flow or a window sees it, so nothing drags, selects or autoscrolls. event.preventDefault(); event.stopPropagation(); element!.setPointerCapture(event.pointerId); const pointer = { x: event.clientX, y: event.clientY }; if (node) carrying = { id: node.id, pointer, from: node.position }; else panning = pointer; }
function drag(event: PointerEvent) { if (carrying) { const { pointer, from } = carrying; const zoom = board.viewport.zoom; board.place(carrying.id, { x: from.x + (event.clientX - pointer.x) / zoom, y: from.y + (event.clientY - pointer.y) / zoom }); return; } if (!panning) return; const { x, y, zoom } = board.viewport; board.setView({ x: x + event.clientX - panning.x, y: y + event.clientY - panning.y, zoom }); panning = { x: event.clientX, y: event.clientY }; }
function release() { if (carrying) { swallow = true; setTimeout(() => (swallow = false)); } panning = null; carrying = null; }
function click(event: MouseEvent) { if (!swallow) return; event.preventDefault(); event.stopPropagation(); }
$effect(() => { if (!element) return; let gesture: 'scroller' | 'stuck' | 'canvas' | null = null; let lastWheel = 0; const wheel = (event: WheelEvent) => { const rect = element!.getBoundingClientRect(); const under = (document.elementFromPoint(event.clientX, event.clientY) ?? event.target) as HTMLElement | null; if (event.altKey) { const node = under?.closest<HTMLElement>('.svelte-flow__node'); if (node?.dataset.id) { event.preventDefault(); board.zoomWindowBy(node.dataset.id, zoomFactor(event)); return; } } const readable = readableAt(under); if (event.ctrlKey || event.metaKey) { // A readable window that zooms itself (a trace's timeline) takes the pinch. if (readable && under?.closest('.zooms-itself')) return; event.preventDefault(); board.zoomAt(zoomFactor(event), { x: event.clientX - rect.left, y: event.clientY - rect.top }); return; } // A menu floating over the canvas, or a view that pans and zooms itself (the agents tree), keeps // every scroll over it, even with nothing left to scroll. if (readable && under?.closest('.holds-wheel')) return; if (event.timeStamp - lastWheel > 140) gesture = null; lastWheel = event.timeStamp; const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX); if (!vertical && readable && under?.closest('.zooms-itself')) return; if (!vertical && gesture !== 'canvas' && scrollsSideways(event, under)) return; const scroller = gesture === 'canvas' ? null : scrollerAt(under, event.deltaY); // A two-finger scroll over a window often starts with a 1-2 px sideways wobble as // both fingers land; calling preventDefault on that first event cancels native // scrolling of the window for the whole gesture. if (scroller && (gesture === 'scroller' || gesture === 'stuck' || vertical || Math.abs(event.deltaX) <= 6)) { const scale = scroller.clientHeight ? scroller.getBoundingClientRect().height / scroller.clientHeight : 1; const by = pixels(event, event.deltaY) / scale; if (gesture === 'stuck') { event.preventDefault(); scroller.scrollTop += by; return; } if (Math.abs(event.deltaY) > 0) gesture = 'scroller'; // The browser scrolls the window. After a pinch over empty canvas, or a pan under // a still pointer, its scroll can stay latched on the canvas and the window never // moves; when a frame passes with room to scroll and no movement, the rest of that // gesture is moved here, by the fingers' own deltas. const from = scroller.scrollTop; const room = by < 0 ? from > 0 : from + scroller.clientHeight < scroller.scrollHeight - 1; if (room && by) requestAnimationFrame(() => requestAnimationFrame(() => { if (gesture !== 'scroller' || scroller.scrollTop !== from) return; gesture = 'stuck'; scroller.scrollTop += by; }), ); return; } gesture = 'canvas'; event.preventDefault(); const { x, y, zoom } = board.viewport; board.setView({ x: x - event.deltaX, y: y - event.deltaY, zoom }); }; element.addEventListener('wheel', wheel, { passive: false, capture: true }); element.addEventListener('pointerdown', grab, { capture: true }); return () => { element?.removeEventListener('wheel', wheel, { capture: true }); element?.removeEventListener('pointerdown', grab, { capture: true }); }; });</script>
<svelte:window onkeydown={keydown} onkeyup={keyup} onblur={() => (hand = alt = false)} />
<div class="canvas" class:hand class:panning={panning !== null} class:alt class:carrying={carrying !== null} bind:this={element} ondblclickcapture={dblclick} onclickcapture={click} onpointermove={drag} onpointerup={release} onpointercancel={release} role="presentation"> <SvelteFlow bind:nodes={board.nodes} edges={board.edges} {nodeTypes} colorMode={prefs.resolved} initialViewport={board.viewport} minZoom={ZOOM.min} maxZoom={ZOOM.max} panOnDrag panOnScroll={false} zoomOnScroll={false} zoomOnPinch={false} zoomOnDoubleClick={false} deleteKey={null} panActivationKey={null} nodesConnectable={false} proOptions={{ hideAttribution: true }} onmove={(_, viewport) => (board.viewport = viewport)} > {#if prefs.dots} <Dots gap={36} size={3} /> {/if} {#if minimap} <MiniMap position="bottom-right" pannable zoomable={false} width={mapWidth} height={Math.round((mapWidth * 104) / 168)} nodeBorderRadius={6} bgColor="var(--surface)" nodeColor="var(--minimap-window)" maskColor="var(--minimap-mask)" /> {/if} <FlowBinder {board} /> </SvelteFlow> <Tethers {board} /> <TraceTie />
{#if board.nodes.length === 0} <div class="empty"> <p class="face kaomoji" aria-hidden="true">(・ω・)ノ</p> <p class="lead">an empty board</p> <p>open a session to start. windows can sit anywhere; pinch to zoom out and see them all. double-click to leave a note.</p> <div class="empty-actions"> <button onclick={onpalette}>open a session <kbd>{shortcut('K')}</kbd></button> <button onclick={onnew}>new session <kbd>S</kbd></button> </div> </div> {/if}</div>
<style> .canvas { position: absolute; inset: 0; } .hand, .hand :global(*) { cursor: grab !important; user-select: none; } .panning, .panning :global(*) { cursor: grabbing !important; } .alt :global(.svelte-flow__node), .alt :global(.svelte-flow__node *), .carrying, .carrying :global(*) { cursor: move !important; user-select: none; } .empty { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 6px; text-align: center; pointer-events: none; } .empty p { max-width: 34ch; margin: 0; color: var(--muted); font-size: calc(13px * var(--ui-scale)); text-wrap: balance; } .empty .face { margin-bottom: 4px; color: var(--agent); font-size: calc(22px * var(--ui-scale)); } .empty .lead { color: var(--text); font-size: calc(15px * var(--ui-scale)); font-weight: 560; } .empty-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 12px; } .empty button { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px 0 14px; color: var(--text); font: inherit; font-size: calc(13px * var(--ui-scale)); background: var(--surface); border: 0; border-radius: 10px; box-shadow: var(--shadow); cursor: pointer; pointer-events: auto; transition: transform 120ms ease-out, box-shadow 160ms ease; } .empty button:hover { box-shadow: var(--shadow-raised); } .empty button:active { transform: scale(0.97); }</style>