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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779<script lang="ts"> import { tick } from 'svelte'; import { boards } from './lib/boards.svelte'; import { sendAllNotes } from './lib/notes'; import BoardView from './lib/tiles/BoardView.svelte'; import { focusToward, nearest, type Direction } from './lib/tiles/focus'; import Marks from './lib/tiles/Marks.svelte'; import { marks, moveToward, sideOf, type Mark } from './lib/tiles/moves'; import { frameOf, paneTrail } from './lib/tiles/tree'; import { ago, place, rank } from './lib/format'; import { command, option, redo, shortcut, typing } from './lib/keys'; import Palette, { type Item } from './lib/Palette.svelte'; import { prefs } from './lib/prefs.svelte'; import { busy } from './lib/live.svelte'; import About from './lib/About.svelte'; import { layout } from './lib/layout.svelte'; import NewSession from './lib/NewSession.svelte'; import FocusChat from './lib/FocusChat.svelte'; import Phone from './lib/Phone.svelte'; import Rail from './lib/Rail.svelte'; import Settings from './lib/Settings.svelte'; import { sessions } from './lib/sessions.svelte'; import { slotOf, type Board } from './lib/board.svelte'; import Tabs from './lib/Tabs.svelte'; import { deleteArtifact, listArtifacts, type Artifact } from './lib/api'; import { artifacts } from './lib/artifacts.svelte'; import * as popouts from './lib/popouts.svelte';
const RAIL_KEY = 'albedo.rail'; let railOpen = $state(localStorage.getItem(RAIL_KEY) !== 'closed'); let paletteOpen = $state(false); /** What the palette opens with typed: `>` from ⌘⇧P, for commands only. */ let paletteStart = $state(''); let settingsOpen = $state(false); /** The pane under the (?): the about card, or the keys. */ let help = $state<'about' | 'keys' | null>(null); let newOpen = $state(false); let search = $state<HTMLInputElement>(); /** The gallery of every artifact, in the palette: to open one, or to delete one. */ let gallery = $state<'open' | 'delete' | null>(null); let shelf = $state<Artifact[]>([]); /** The letters of ⌥/ while picking a window by them; `arming` waits for the arrow after ⌥P. */ let picks = $state.raw<Mark[] | null>(null); let arming = $state(false); let note = $state<string | null>(null); let noteTimer: ReturnType<typeof setTimeout> | undefined; const hint = $derived( picks ? boards.board?.presel ? 'a letter: move that window into the preselection · esc' : 'a letter: swap it with this one · ⇧ letter: join it as a tab · esc' : arming ? 'an arrow: preselect that side of this pane · esc' : note, );
// The palette finds artifacts too, so the list is read whenever it opens. $effect(() => { if (paletteOpen || gallery) listArtifacts().then((found) => (shelf = found), () => {}); });
$effect(() => localStorage.setItem(RAIL_KEY, railOpen ? 'open' : 'closed'));
// With the sessions hidden, the pointer at the canvas's edge on their side // brings them out floating over it, at once. How near counts as the edge // grows with how fast the pointer is heading there, so a flick toward the // edge opens it early and a slow drift along the canvas only right at it. // It is watched from the canvas rather than a strip along the edge, so the // edge still pans and clicks like the rest of the canvas. Out, the card // stays while the pointer is on it or near it (the top bar above it // included, for an overshoot toward the first rows), while its filter has // focus, and goes soon after the pointer is clearly elsewhere or leaves // the page. const PEEK_EDGE = 20; const PEEK_FLICK_EDGE = 50; /** px of edge gained per px/ms of speed toward it */ const PEEK_PER_SPEED = 15; const PEEK_NEAR = 48; const PEEK_LINGER_MS = 100; let peek = $state(false); let peekEl = $state<HTMLElement>(); /** The pointer's last distance from that edge, and its speed toward it, smoothed. */ const heading = { x: 0, t: 0, speed: 0 }; let peekTimer: ReturnType<typeof setTimeout> | undefined; let pointerNear = false; $effect(() => { if (railOpen) peek = false; });
function edge(event: PointerEvent) { if (railOpen || peek) return; const canvas = (event.currentTarget as HTMLElement).getBoundingClientRect(); const x = prefs.sidebarRight ? canvas.right - event.clientX : event.clientX - canvas.left; const dt = event.timeStamp - heading.t; const toward = dt > 0 && dt < 100 ? Math.max(0, (heading.x - x) / dt) : 0; heading.speed = heading.speed * 0.5 + toward * 0.5; heading.x = x; heading.t = event.timeStamp; const reach = Math.min(PEEK_FLICK_EDGE, PEEK_EDGE + heading.speed * PEEK_PER_SPEED); if (x <= reach && !event.buttons) peek = true; }
function near(event: PointerEvent) { if (!peek || !peekEl) { pointerNear = false; return; } const card = peekEl.getBoundingClientRect(); const beside = prefs.sidebarRight ? event.clientX >= card.left - PEEK_NEAR : event.clientX <= card.right + PEEK_NEAR; pointerNear = beside && event.clientY <= card.bottom + PEEK_NEAR; if (pointerNear) clearTimeout(peekTimer); else unpeek(); }
function unpeek() { clearTimeout(peekTimer); peekTimer = setTimeout(() => { // Typing in its filter keeps it out. if (!peekEl?.contains(document.activeElement)) peek = false; }, PEEK_LINGER_MS); }
sessions.start(); popouts.start();
function focusFilter() { if (!railOpen) peek = true; tick().then(() => search?.focus()); }
function openTerminal(cwd?: string, sessionId?: string) { const id = boards.board?.selected; const selected = id ? boards.board?.windows[id] : undefined; const session = selected?.type === 'session' ? sessions.byId.get(selected.data.sessionId) : undefined; const targetCwd = cwd ?? session?.workspace ?? undefined; const targetSessionId = sessionId ?? session?.id ?? undefined; boards.board?.openPty({ cwd: targetCwd, sessionId: targetSessionId, parent: selected?.id }); }
const ARROWS: Record<Direction, string> = { left: '←', right: '→', up: '↑', down: '↓' }; const TOWARD: Record<string, Direction | undefined> = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' };
/** `canvas`: only offered on a board with a canvas, which it works on. */ const commands: (Omit<Item, 'kind' | 'key'> & { canvas?: boolean })[] = [ { label: 'new session', hint: 'S', run: () => (newOpen = true) }, { label: 'new terminal', hint: 'T', run: () => openTerminal() }, { label: 'new board', hint: 'B', run: () => boards.create() }, { label: 'rename board', hint: 'F2', run: () => (boards.renaming = boards.active) }, { label: 'next board', hint: '⇧]', run: () => boards.step(1) }, { label: 'previous board', hint: '⇧[', run: () => boards.step(-1) }, { label: 'close board tab', hint: '⇧W', run: () => boards.active && boards.close(boards.active) }, { label: 'delete board', run: () => boards.active && boards.delete(boards.active) }, { label: 'new note', hint: 'N', run: () => boards.board?.noteAt() }, { label: 'send all notes', detail: 'each chat gets its own as one message', run: () => boards.board && sendAllNotes(boards.board) }, { label: 'keyboard shortcuts', hint: '?', run: () => (help = 'keys') }, { label: 'about albedo', run: () => (help = 'about') }, { label: 'fit all windows', hint: '⇧1', canvas: true, run: () => boards.board?.fit() }, { label: 'zoom to 100%', hint: '⇧0', canvas: true, run: () => boards.board?.zoomTo(1) }, { label: 'zoom window in', hint: option('+'), run: () => zoomSelectedWindow(1) }, { label: 'zoom window out', hint: option('−'), run: () => zoomSelectedWindow(-1) }, { label: 'window zoom to 100%', hint: option('0'), run: () => zoomSelectedWindow(0) }, ...(Object.entries(ARROWS) as [Direction, string][]).flatMap(([toward, arrow]) => [ { label: `move pane ${toward}`, hint: option(`⇧${arrow}`), detail: 'past its neighbour, into tabs or a split, out at an edge', run: () => moveSelected(toward) }, { label: `swap window ${toward}`, hint: option(`⌃${arrow}`), detail: 'with the window that way', run: () => swapWindow(toward) }, ]), { label: 'pick a window by letter', hint: option('/'), detail: 'a letter swaps it with this one, ⇧ letter joins it as a tab', run: () => startPick() }, { label: 'preselect where the next window goes', hint: option('P'), detail: 'then an arrow: that side of this pane', run: () => arm() }, { label: 'clear the preselection', hint: 'Esc', run: () => boards.board && (boards.board.presel = null) }, { label: 'focus pane', hint: option('F'), detail: 'it fills the board for now; the rest waits behind it', run: () => toggleExpanded() }, { label: 'unfocus', hint: 'Esc', detail: 'back to the whole board', run: () => boards.board?.collapse() }, { label: 'make section', hint: option('G'), detail: `of what is ${command}-clicked: title bars, section headers, a canvas`, run: () => makeSection() }, { label: 'undo layout change', hint: shortcut('Z'), run: () => boards.board?.undo() }, { label: 'redo layout change', hint: redo, run: () => boards.board?.redo() }, { label: 'next window', run: () => boards.board?.step(1) }, { label: 'previous window', run: () => boards.board?.step(-1) }, { label: 'layout: canvas', detail: 'windows anywhere on a plane', run: () => boards.board?.setRoot('canvas') }, { label: 'layout: tiles', detail: 'windows side by side, filling the board', run: () => boards.board?.setRoot('split') }, { label: 'layout: tabs', detail: 'one window at a time', run: () => boards.board?.setRoot('tabs') }, { label: 'new canvas beside this window', run: () => canvasBeside() }, { label: 'new canvas on this canvas', detail: 'panned and zoomed on its own; windows drag in and out', canvas: true, run: () => boards.board?.addCanvasOn() }, { label: 'close pane', hint: 'W', detail: 'the selected window, section or canvas, with everything in it', run: () => closeSelected() }, { label: 'move to a new board', detail: 'the picked or selected pane, with everything in it', run: () => toNewBoard() }, { label: 'pop out pane', detail: 'the selected window, section or canvas, into a browser window of its own; closing that puts it back', run: () => popOutSelected() }, { label: 'toggle overview map', hint: 'M', canvas: true, run: () => boards.board?.toggleMap() }, { label: 'toggle sessions', hint: '[', run: () => (railOpen = !railOpen) }, { label: 'filter sessions', hint: '/', run: () => focusFilter() }, { label: 'refresh sessions', hint: 'R', run: () => sessions.refresh() }, { label: 'settings', hint: shortcut(','), run: () => (settingsOpen = true) }, { label: 'theme: follow the system', run: () => (prefs.theme = 'system') }, { label: 'theme: light', run: () => (prefs.theme = 'light') }, { label: 'theme: dark', run: () => (prefs.theme = 'dark') }, { label: 'open an artifact…', hint: 'A', run: () => (gallery = 'open') }, { label: 'delete an artifact…', run: () => (gallery = 'delete') }, ];
/** Opens an artifact beside the chat it came from, when that chat is on the board. */ function openArtifact(artifact: Artifact) { const chat = `session:${artifact.session_id}`; const parent = boards.board?.windows[chat] ? chat : undefined; boards.board?.openArtifact(artifact.id, artifact.session_id, artifact.version, parent); }
async function removeArtifact(artifact: Artifact) { await deleteArtifact(artifact.id); artifacts.forget(artifact.id); boards.board?.remove(`artifact:${artifact.id}`, { undoable: false }); shelf = shelf.filter((item) => item.id !== artifact.id); }
function artifactItems(query: string, deleting = false): Item[] { return rank(shelf, query, (artifact) => [artifact.title, artifact.session_name ?? '']).map<Item>((artifact) => ({ key: `artifact:${artifact.id}`, kind: 'artifact', label: deleting ? `delete “${artifact.title}”` : artifact.title, detail: [ artifact.versions > 1 ? `v${artifact.version}` : '', artifact.session_deleted ? 'from a deleted session' : artifact.session_name || place(artifact.workspace), ago(new Date(artifact.updated_at).toISOString()), ] .filter(Boolean) .join(' · '), run: () => (deleting ? removeArtifact(artifact) : openArtifact(artifact)), })); }
function searchEverything(query: string): Item[] { const found = rank( sessions.list.filter((session) => !session.preferences.archived), query, (session) => [session.name, place(session.workspace)], ) .slice(0, 60) .map<Item>((session) => ({ key: session.id, kind: 'session', label: session.name || 'untitled session', detail: `${place(session.workspace)} · ${busy(session) ? 'working' : ago(session.activity_at)}`, run: () => boards.openSession(session), })); const reopen = boards.closed.map((entry) => ({ label: `open board: ${entry.name}`, run: () => boards.show(entry.id) })); const offered = boards.board?.canvas ? commands : commands.filter((command) => !command.canvas); const actions = rank([...offered, ...reopen], query, (command) => command.label).map<Item>((command) => ({ key: `command:${command.label}`, kind: 'command', ...command })); return query ? [...found, ...artifactItems(query).slice(0, 20), ...actions] : [...found, ...actions]; }
/** The palette's search: everything, or after `>` only commands, after `@` only sessions, as in VS Code's quick open. */ function searchPalette(query: string): Item[] { const [mark, rest] = [query[0], query.slice(1).trimStart()]; if (mark === '>') return searchEverything(rest).filter((item) => item.kind === 'command'); if (mark === '@') return searchEverything(rest).filter((item) => item.kind === 'session'); return searchEverything(query); }
/** The pane a key acts on: the selected one, else the window under the pointer, else the board's only window. */ function targetPane(): string | null { const board = boards.board; if (!board) return null; if (board.selected) return board.selected; const hovered = document.querySelector<HTMLElement>('.window-slot:hover')?.dataset.id; if (hovered) return hovered; return board.list.length === 1 ? board.list[0].id : null; }
function targetWindowId(): string | null { const id = targetPane(); return id && boards.board?.windows[id] ? id : null; }
function zoomSelectedWindow(by: 1 | -1 | 0) { const id = targetWindowId(); if (!id) return say('select a window first'); if (by === 0) boards.board?.zoomWindow(id, 1); else boards.board?.stepWindowZoom(id, by); }
/** Moves the focus to the window that way on screen, bringing it into view on its canvas. */ function focusWindow(direction: 'left' | 'right' | 'up' | 'down') { const board = boards.board; const id = board && focusToward(board, direction); if (id) board.reveal(id); }
function say(text: string) { clearTimeout(noteTimer); note = text; noteTimer = setTimeout(() => (note = null), 1800); }
/** Makes one layout change; the board refuses one that would leave a window too small to use. */ function arrange(change: (board: Board) => unknown, nothing: string) { const board = boards.board; if (!board) return; const before = board.root; if (change(board)) return say('no room: a window would get too small'); if (board.root === before) say(nothing); }
function moveSelected(toward: Direction) { const board = boards.board; const id = board?.selected; if (!board || !id) return; const window = !!board.windows[id]; if (window && board.placeOf(id) === 'canvas') return say(`alone on a canvas: drag it, or ${option('⌃')} an arrow to swap`); if (!window && paneTrail(board.root, id)?.at(-2)?.kind === 'canvas') return say('on a canvas: drag it by its header'); arrange((board) => { const move = moveToward(board.root, id, sideOf[toward]); return move && (window ? board.move(id, move.target, move.where, { index: move.index }) : board.moveTile(id, move.target, move.where, move.index)); }, 'already at the edge'); }
/** Trades places with the window that way on screen; notes are not in the layout to trade. */ function swapWindow(toward: Direction) { const board = boards.board; const window = board?.selected; if (!board || !window) return; if (!board.windows[window]) return say('only windows swap'); const other = nearest(board, toward, { skip: (id) => board.windows[id]?.type === 'note', beyond: true }); if (!other) return say('no window that way'); arrange((board) => board.swap(window, other), 'no window that way'); }
function startPick() { const board = boards.board; arming = false; picks = board && !picks ? marks(board) : null; }
function arm() { picks = null; arming = !!boards.board?.selected; }
/** What a picked letter does: moves the picked window into the preselection, or swaps it with the selected one, or (with ⇧) joins the selected one to it as a tab. */ function pick(letter: string, join: boolean) { const board = boards.board; const picked = picks?.find((mark) => mark.letter === letter)?.window; const window = board?.selected; picks = null; if (!board || !picked) return; const presel = board.presel; if (presel) { board.presel = null; const target = frameOf(board.root, presel.pane); if (target && picked !== presel.pane) arrange((board) => board.move(picked, target.id, presel.side), 'it is already there'); return; } if (!window || picked === window) return say('that is this window'); if (!join && !board.windows[window]) return say('only windows swap'); if (!join) return arrange((board) => board.swap(window, picked), 'nothing to swap'); const target = frameOf(board.root, picked); if (!target || target === frameOf(board.root, window)) return say('already together'); arrange((board) => (board.windows[window] ? board.move(window, target.id, 'tab') : board.moveTile(window, target.id, 'tab')), 'already together'); }
/** A canvas's key: on the canvas it works on, or a word that there is none. */ function onCanvas(run: (board: Board) => void) { const board = boards.board; if (board?.canvas) run(board); else say('no canvas on this board'); }
/** Fills the board with the selected pane, or puts back whatever fills it. */ function toggleExpanded() { const board = boards.board; const id = board?.expanded ?? targetPane(); if (!board || !id) return say('select a pane first'); if (!board.expandable(id)) return say('nothing else to fill the board over'); board.toggleExpanded(id); }
function makeSection() { const problem = boards.board?.makeSection(); if (problem) say(problem); }
/** The layout keys: ⌥⇧ and ⌥⌃ with an arrow, ⌥/ and its letters, ⌥P and its arrow, ⌥G, ⌥F, Esc. True when the key was theirs. */ function layoutKey(event: KeyboardEvent): boolean { const toward = TOWARD[event.key]; if (event.key === 'Escape') { if (picks) picks = null; else if (arming) arming = false; else if (boards.board?.picked.size) boards.board.unpick(); else if (boards.board?.presel) (boards.board.presel = null), say('preselection cleared'); else if (boards.board?.expanded) boards.board.collapse(); else return false; return true; } if (picks && /^Key[A-Z]$/.test(event.code) && !event.altKey && !event.metaKey && !event.ctrlKey) { pick(event.code.slice(3).toLowerCase(), event.shiftKey); return true; } if (arming && toward) { const board = boards.board; arming = false; if (board?.selected) board.presel = { pane: board.selected, side: sideOf[toward] }; return true; } if (!event.altKey || event.metaKey) return false; if (event.code === 'Slash') startPick(); else if (event.code === 'KeyP') arm(); else if (event.code === 'KeyG' && !event.shiftKey && !event.ctrlKey) makeSection(); else if (event.code === 'KeyF' && !event.shiftKey && !event.ctrlKey) toggleExpanded(); else if (toward && event.shiftKey && !event.ctrlKey) moveSelected(toward); else if (toward && event.ctrlKey && !event.shiftKey) swapWindow(toward); else return false; return true; }
/** ⌘Z or Ctrl+Z undoes a layout change; ⇧⌘Z, Ctrl+⇧Z or Ctrl+Y redoes it. */ function undoKey(event: KeyboardEvent): boolean { if (!(event.metaKey || event.ctrlKey) || event.altKey) return false; if (event.code === 'KeyZ') event.shiftKey ? boards.board?.redo() : boards.board?.undo(); else if (event.code === 'KeyY' && event.ctrlKey) boards.board?.redo(); else return false; return true; }
/** Splits a new, empty canvas beside the selected pane, or beside the tabs it sits in. */ function canvasBeside() { const board = boards.board; const frame = board?.selected ? frameOf(board.root, board.selected) : null; if (board && frame) board.addCanvas(frame.id, 'right'); }
function popOutSelected() { const id = targetPane(); if (id) popouts.popOut(id); else say('select a pane first'); }
/** Moves the one picked pane, else the selected one, onto a board of its own. */ function toNewBoard() { const board = boards.board; if (!board) return; const picked = [...board.picked]; if (picked.length > 1) return say('pick just one to move to a new board'); const id = picked[0] ?? targetPane(); if (!id) return say('select a pane first'); board.unpick(); if (!boards.popToBoard(id)) say('that is the whole board already'); }
function closeSelected() { const board = boards.board; const selected = board?.selected; if (!board || !selected) return; if (board.windows[selected]) board.remove(selected); else board.closeTile(selected); }
function focusSelectedWindow(): boolean { const id = targetWindowId(); if (!id) return false; const node = slotOf(id); const field = node?.querySelector<HTMLElement>('.editor, textarea, input[type="search"], input[type="text"]'); if (!field) return false; field.focus(); return true; }
function keydown(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key === ',') { event.preventDefault(); settingsOpen = !settingsOpen; return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); paletteStart = ''; paletteOpen = !paletteOpen; return; } if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'p') { event.preventDefault(); paletteStart = '>'; paletteOpen = !paletteOpen; return; } if (paletteOpen || gallery || newOpen || layout.focus || typing(event)) return; if (layoutKey(event) || undoKey(event)) return event.preventDefault(); if (event.metaKey || event.ctrlKey) return; if (event.altKey) { const toward = TOWARD[event.key]; if (toward) focusWindow(toward); else if (event.code === 'Equal' || event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') zoomSelectedWindow(1); else if (event.code === 'Minus' || event.code === 'NumpadSubtract' || event.key === '-' || event.key === '_') zoomSelectedWindow(-1); else if (event.code === 'Digit0' || event.code === 'Numpad0' || event.key === '0') zoomSelectedWindow(0); else return; return event.preventDefault(); } if (event.key === '[') railOpen = !railOpen; else if (event.key === '{') boards.step(-1); else if (event.key === '}') boards.step(1); else if (event.key === 'F2') boards.renaming = boards.active; else if (event.key === '!' && event.code === 'Digit1') onCanvas((board) => board.fit()); else if (event.key === ')' && event.code === 'Digit0') onCanvas((board) => board.zoomTo(1)); else if (event.key === 's' || event.key === 'c' || event.key === 'N') newOpen = true; else if (event.key === 't' || event.key === 'T') openTerminal(); else if (event.key === 'b') boards.create(); else if (event.key === 'w') closeSelected(); else if (event.key === 'W') boards.active && boards.close(boards.active); else if (event.key === 'a') gallery = 'open'; else if (event.key === 'r') sessions.refresh(); else if (event.key === '?') help = help === 'keys' ? null : 'keys'; else if (event.key === 'm') onCanvas((board) => board.toggleMap()); else if (event.key === 'n') boards.board?.noteAt(); else if (event.key === '+' || event.key === '=') onCanvas((board) => board.stepZoom(1)); else if (event.key === '-' || event.key === '_') onCanvas((board) => board.stepZoom(-1)); else if (event.key === '/') focusFilter(); else if (event.key === 'Enter' && (event.target === document.body || (event.target as Element | null)?.closest('.svelte-flow'))) { if (!focusSelectedWindow()) return; } else return; event.preventDefault(); }
const linkLabel = { connecting: 'connecting', online: 'connected', offline: 'daemon unreachable', unauthorized: 'signed out: open the link albedo web printed' };</script>
<svelte:window onkeydown={keydown} onpointerdowncapture={() => ((picks = null), (arming = false))} onpointermove={near} onmouseout={(event) => peek && !event.relatedTarget && ((pointerNear = false), unpeek())} />
{#if layout.phone} <Phone onsettings={() => (settingsOpen = !settingsOpen)} onnew={() => (newOpen = true)} />{:else}<div class="shell" class:rail-closed={!railOpen} class:right={prefs.sidebarRight}> <header class="topbar"> <div class="start"> <button class="icon rail-toggle" aria-label={railOpen ? 'Hide sessions' : 'Show sessions'} title="{railOpen ? 'Hide' : 'Show'} sessions ([)" onclick={() => (railOpen = !railOpen)}> <svg viewBox="0 0 16 16" width="16" height="16" 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="M6.25 3v10" stroke="currentColor" stroke-width="1.3" /></svg> <kbd>[</kbd> </button> <span class="brand"><span class="spark" aria-hidden="true">✦</span> <span class="brand-ink">albedo</span></span> </div> <Tabs /> {#if sessions.link !== 'online'} <span class="link" role="status">{linkLabel[sessions.link]}</span> {/if} <div class="end"> <button class="command" onclick={() => (paletteOpen = true)}> <span>search</span> <kbd>{shortcut('K')}</kbd> </button> <button class="icon about" data-about-toggle aria-label="about albedo" aria-expanded={help === 'about'} title="about albedo" onclick={() => (help = help === 'about' ? null : 'about')}> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.3" /><path d="M6.4 6.3a1.7 1.7 0 0 1 3.3.5c0 1.1-1.7 1.4-1.7 2.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" /><circle cx="8" cy="11.4" r="0.8" fill="currentColor" /></svg> </button> <button class="icon" class:on={settingsOpen} data-settings-toggle aria-label="Settings" aria-expanded={settingsOpen} title="Settings ({shortcut(',')})" onclick={() => (settingsOpen = !settingsOpen)}> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2.5 5h6M11.5 5h2M2.5 11h2M7.5 11h6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" /><circle cx="10" cy="5" r="1.6" fill="none" stroke="currentColor" stroke-width="1.3" /><circle cx="6" cy="11" r="1.6" fill="none" stroke="currentColor" stroke-width="1.3" /></svg> </button> </div> </header>
{#if railOpen}<Rail bind:search onnew={() => (newOpen = true)} />{/if}
<main onpointermovecapture={edge}> {#if peek} <div class="peek" bind:this={peekEl} onfocusout={(event) => !pointerNear && !peekEl?.contains(event.relatedTarget as Node) && unpeek()} > <Rail bind:search onnew={() => (newOpen = true)} /> </div> {/if} {#if boards.board} {#key boards.board} <BoardView board={boards.board} onpalette={() => (paletteOpen = true)} onnew={() => (newOpen = true)} /> <Marks board={boards.board} picks={picks ?? []} {hint} /> {/key} {/if} </main></div>
{/if}
{#if layout.focus && !layout.phone} {#key layout.focus}<FocusChat sessionId={layout.focus} />{/key}{/if}
{#if settingsOpen} <Settings onclose={() => (settingsOpen = false)} />{/if}
{#if newOpen} <NewSession onclose={() => (newOpen = false)} />{/if}
{#if help} <About bind:view={() => help ?? 'about', (view) => (help = view)} onclose={() => (help = null)} />{/if}
{#if paletteOpen} <Palette placeholder="open a session or run a command… (> commands, @ sessions)" search={searchPalette} start={paletteStart} onclose={() => ((paletteOpen = false), (paletteStart = ''))} />{/if}
{#if gallery} {#key gallery} <Palette placeholder={gallery === 'open' ? 'open an artifact…' : 'delete an artifact, for good…'} search={(query) => artifactItems(query, gallery === 'delete')} onclose={() => (gallery = null)} /> {/key}{/if}
<style> .shell { display: grid; grid-template-rows: 44px 1fr; grid-template-columns: 264px 1fr; height: 100%; } .shell.rail-closed { grid-template-columns: 1fr; } .topbar { display: grid; grid-column: 1 / -1; grid-template-columns: 264px 1fr auto auto; grid-template-areas: 'start tabs link end'; align-items: stretch; background: var(--rail); } .rail-closed .topbar { grid-template-columns: auto 1fr auto auto; } .topbar > :global(.tabs) { grid-area: tabs; } .link { grid-area: link; } .end { grid-area: end; } /* Mirrored: the sessions on the right, with their toggle and the brand above them, and search and settings on the left. */ .shell.right { grid-template-columns: 1fr 264px; } .shell.right.rail-closed { grid-template-columns: 1fr; } .shell.right > :global(aside) { grid-area: 2 / 2; } .shell.right > main { grid-area: 2 / 1; } .right .topbar { grid-template-columns: auto 1fr auto 264px; grid-template-areas: 'end tabs link start'; } .right.rail-closed .topbar { grid-template-columns: auto 1fr auto auto; } .right .start { flex-direction: row-reverse; padding: 0 10px 0 14px; } .right .end { padding: 0 6px 0 8px; } .right .rail-toggle svg { transform: scaleX(-1); } /* One control with its key, on the same surface as search. */ .icon.rail-toggle { display: flex; align-items: center; gap: 6px; width: auto; padding: 0 5px 0 7px; background: var(--field); } /* A step of ink on the button, so the key reads as its own little surface. */ .command kbd, .rail-toggle kbd { background: color-mix(in oklab, var(--text) 12%, transparent); } .start { display: flex; grid-area: start; align-items: center; gap: 8px; padding: 0 14px 0 10px; } .brand { font-size: calc(14px * var(--ui-scale)); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; } .spark { color: var(--brand-from); font-size: calc(12px * var(--ui-scale)); } .icon, .command { align-self: center; color: var(--muted); font: inherit; background: none; border: 0; border-radius: 8px; cursor: pointer; transition: color 120ms ease, background-color 120ms ease; } .icon { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; } .about { width: 24px; height: 24px; color: var(--faint); } .end { display: flex; align-items: center; gap: 4px; padding: 0 8px 0 6px; } .icon.on { color: var(--text); background: var(--selected); } .command { display: flex; align-items: center; gap: 12px; height: 30px; padding: 0 5px 0 12px; font-size: calc(13px * var(--ui-scale)); background: var(--field); } .icon:hover, .command:hover { color: var(--text); background: var(--hover); } .command:hover, .icon.rail-toggle:hover { background: var(--selected); } .link { align-self: center; color: var(--danger); font-size: calc(12px * var(--ui-scale)); } main { position: relative; min-width: 0; min-height: 0; overflow: hidden; } /* The hidden sessions, out over the canvas while the pointer is at its edge. */ .peek { position: absolute; top: 8px; bottom: 8px; left: 8px; z-index: var(--z-peek); display: flex; width: 264px; overflow: hidden; border-radius: 14px; box-shadow: var(--shadow-lg); } .peek > :global(aside) { flex: 1; } .right .peek { right: 8px; left: auto; }</style>