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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404<script lang="ts"> import type { Session } from './api'; import { boards } from './boards.svelte'; import { ago, homePath, matches, place } from './format'; import { busy } from './live.svelte'; import LiveFace from './LiveFace.svelte'; import { tick } from 'svelte'; import { sessions } from './sessions.svelte';
let { search = $bindable(), onnew }: { search?: HTMLInputElement; onnew?: () => void } = $props(); let query = $state('');
const found = $derived(sessions.list.filter((session) => !query || matches(query, session.name, place(session.workspace)))); const visible = $derived(found.filter((session) => !session.preferences.archived)); const archived = $derived(found.filter((session) => session.preferences.archived)); let showArchived = $state(false); // Grouped as the terminal groups them: pinned, then by when they last moved. const DAY = 86_400_000; const sections = $derived.by(() => { const midnight = new Date().setHours(0, 0, 0, 0); const when = (session: Session) => Date.parse(session.activity_at ?? '') || 0; const named: [string, (session: Session) => boolean][] = [ ['pinned', (session) => session.preferences.pinned], ['today', (session) => when(session) >= midnight], ['this week', (session) => when(session) >= midnight - 6 * DAY], ['earlier', () => true], ]; const left = [...visible].sort((a, b) => (a.preferences.pin_order ?? Infinity) - (b.preferences.pin_order ?? Infinity)); return named .map(([label, belongs]) => { const rows = left.filter(belongs); for (const row of rows) left.splice(left.indexOf(row), 1); return { label, rows }; }) .filter((section) => section.rows.length); }); const onBoard = $derived(new Set(boards.board?.nodes.flatMap((node) => (node.type === 'note' || node.type === 'artifact' ? [] : [node.data.sessionId]))));
// Pinned rows move among themselves: dragged, or with Alt and the arrow keys. let dragging = $state<string | null>(null); let over = $state<{ id: string; after: boolean } | null>(null);
function pointed(event: DragEvent, session: Session) { if (!dragging || dragging === session.id || !session.preferences.pinned) return; event.preventDefault(); const box = (event.currentTarget as HTMLElement).getBoundingClientRect(); over = { id: session.id, after: event.clientY > box.top + box.height / 2 }; }
function dropped() { if (dragging && over) { const rest = sessions.pins.map((pin) => pin.id).filter((id) => id !== dragging); const at = rest.indexOf(over.id); sessions.movePin(dragging, over.after ? (rest[at + 1] ?? null) : over.id); } dragging = over = null; }
async function nudge(event: KeyboardEvent, session: Session) { if (!event.altKey || !session.preferences.pinned || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return; event.preventDefault(); const ids = sessions.pins.map((pin) => pin.id); const at = ids.indexOf(session.id); const button = event.currentTarget as HTMLElement; if (event.key === 'ArrowUp' ? at > 0 : at < ids.length - 1) { await sessions.movePin(session.id, event.key === 'ArrowUp' ? ids[at - 1] : (ids[at + 2] ?? null)); await tick(); button.focus(); } }
function submit(event: SubmitEvent) { event.preventDefault(); if (visible[0]) boards.openSession(visible[0]); }</script>
<aside> <div class="top"> <form class="search" onsubmit={submit}> <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4" /><path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" /></svg> <input bind:this={search} bind:value={query} placeholder="filter sessions" aria-label="Filter sessions" spellcheck="false" onkeydown={(event) => event.key === 'Escape' && ((query = ''), search?.blur())} /> <kbd>/</kbd> </form> {#if onnew} <button class="new" aria-label="new session" title="new session (S)" onclick={onnew}> <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 3.5v9M3.5 8h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg> <kbd>S</kbd> </button> {/if} </div>
<nav> {#each sections as section (section.label)} <h3>{section.label}<span class="count">{section.rows.length}</span></h3> <div class="rows"> {#each section.rows as session (session.id)}{@render row(session)}{/each} </div> {/each} {#if archived.length} <button class="fold" aria-expanded={showArchived || !!query} onclick={() => (showArchived = !showArchived)}> <span class="chev" class:down={showArchived || !!query}>›</span>archived<span class="count">{archived.length}</span> </button> {#if showArchived || query} <div class="rows"> {#each archived as session (session.id)}{@render row(session)}{/each} </div> {/if} {/if} {#if sessions.problem}<p class="none" role="alert">{sessions.problem}</p>{/if} {#if sessions.loaded && visible.length === 0} <p class="none">{query ? 'nothing matches (・_・ )' : 'no sessions yet (・ω・)ノ'}</p> {:else if !sessions.loaded && sessions.link === 'connecting'} {#each { length: 6 } as _}<div class="ghost"></div>{/each} {/if} </nav></aside>
{#snippet row(session: Session)} <div class="row" class:open={onBoard.has(session.id)} class:archived={session.preferences.archived} class:dragging={dragging === session.id} class:before={over?.id === session.id && !over.after} class:after={over?.id === session.id && over.after} draggable={session.preferences.pinned} role="presentation" ondragstart={() => (dragging = session.id)} ondragover={(event) => pointed(event, session)} ondrop={dropped} ondragend={() => (dragging = over = null)} > <button class="main" onclick={() => boards.openSession(session)} onkeydown={(event) => nudge(event, session)} title="{session.name}\n{homePath(session.workspace)}{session.preferences.pinned ? '\nmove: drag, or Alt + ↑ ↓' : ''}" > <span class="name">{#if session.preferences.pinned}<span class="star" aria-label="pinned">★</span>{/if}{session.name || 'untitled session'}</span> {#if busy(session)} <span class="when"><LiveFace {session} /></span> {:else} <span class="when">{ago(session.activity_at)}</span> {/if} <span class="where">{place(session.workspace)}</span> </button> <span class="actions"> {#if !session.preferences.archived} <button aria-label={session.preferences.pinned ? 'unpin' : 'pin'} title={session.preferences.pinned ? 'unpin' : 'pin'} onclick={() => sessions.mark(session.id, { pinned: !session.preferences.pinned })}>{session.preferences.pinned ? '★' : '☆'}</button> {/if} <button aria-label={session.preferences.archived ? 'unarchive' : 'archive'} title={session.preferences.archived ? 'unarchive' : 'archive'} onclick={() => sessions.mark(session.id, { archived: !session.preferences.archived })}> <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2.5 4.5h11v2h-11zM3.5 6.5v6h9v-6M6.5 9h3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" stroke-linecap="round" /></svg> </button> </span> </div>{/snippet}
<style> aside { display: flex; flex-direction: column; min-height: 0; padding-top: 10px; background: var(--rail); } /* The filter, and beside it a new session. */ .top { display: flex; gap: 6px; margin: 0 10px 8px; } .search { display: flex; flex: 1; align-items: center; gap: 8px; min-width: 0; height: 30px; padding: 0 6px 0 9px; color: var(--muted); background: var(--field); border-radius: 8px; transition: box-shadow 120ms ease; } .search:focus-within { box-shadow: 0 0 0 2px var(--focus-ring); } .search input { flex: 1; min-width: 0; padding: 0; color: var(--text); font: inherit; font-size: calc(12.5px * var(--ui-scale)); background: none; border: 0; outline: 0; } .search:focus-within kbd { visibility: hidden; } .new { display: flex; flex: none; align-items: center; gap: 5px; height: 30px; padding: 0 5px 0 7px; background: var(--field); border: 0; border-radius: 8px; cursor: pointer; transition: background-color 120ms ease; } .new:hover { background: var(--selected); } .new svg { color: var(--accent); } .new kbd { background: color-mix(in oklab, var(--text) 12%, transparent); } nav { flex: 1; min-height: 0; padding: 0 6px 12px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } /* As the terminal's section rules: the label, its count, then a rule to the edge. */ h3 { display: flex; align-items: center; gap: 8px; margin: 16px 10px 6px; color: var(--text); font-size: calc(12.5px * var(--ui-scale)); font-weight: 650; } h3::after { flex: 1; height: 1px; background: color-mix(in oklab, var(--text) 28%, transparent); content: ''; } .count { color: var(--faint); font-weight: 400; } .star { margin-right: 5px; color: var(--agent); font-size: calc(11px * var(--ui-scale)); } .row { position: relative; border-radius: 7px; transition: background-color 100ms ease; } .main { display: grid; grid-template-columns: 1fr auto; grid-template-areas: 'name when' 'where where'; align-items: baseline; gap: 1px 10px; width: 100%; padding: 7px 10px; color: inherit; font: inherit; text-align: left; background: none; border: 0; border-radius: inherit; cursor: pointer; } .archived .main { opacity: 0.65; } .row.dragging { opacity: 0.45; } /* Where a dragged pin would land: a line in the accent on that side of the row. */ .row.before { box-shadow: 0 -2px 0 var(--accent); } .row.after { box-shadow: 0 2px 0 var(--accent); } /* Pin and archive sit over the time while the row is pointed at or has focus within. */ .actions { position: absolute; top: 4px; right: 6px; display: none; gap: 2px; padding: 1px; background: var(--rail); border-radius: 6px; } .row:hover .actions, .row:focus-within .actions { display: flex; } .actions button { display: grid; place-items: center; width: 24px; height: 22px; padding: 0; color: var(--muted); font-size: calc(12px * var(--ui-scale)); background: none; border: 0; border-radius: 5px; cursor: pointer; } .actions button:hover { color: var(--text); background: var(--selected); } .fold { display: flex; align-items: center; gap: 6px; width: 100%; margin: 16px 0 4px; padding: 2px 10px; color: var(--muted); font: inherit; font-size: calc(12.5px * var(--ui-scale)); background: none; border: 0; cursor: pointer; } .chev { display: inline-block; transition: transform 100ms ease; } .chev.down { transform: rotate(90deg); } /* Every other row a step down from the rail, as the phone's list steps from its ground, starting again in each section. Hover lays a step over a row's own tone, so it never reads as a stripe. */ .rows > .row:nth-child(odd) { background-color: var(--rail-stripe); } .row:hover { background-image: linear-gradient(var(--selected), var(--selected)); } /* Open on the board: one flat tint of the accent, opaque so the stripes don't show through it and a run of open rows reads as one block, its meeting corners squared. Hover still lays its step over it. */ .rows > .row.open { background-color: var(--open-tint); } .row.open:has(+ .row.open) { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } /* The rows sit at fractional heights, so the edges the two meet on come out half covered; a pixel of the tint over the join closes it. */ .row.open + .row.open { border-top-left-radius: 0; border-top-right-radius: 0; box-shadow: 0 -1px 0 var(--open-tint); } .name { grid-area: name; overflow: hidden; font-size: calc(12.5px * var(--ui-scale)); white-space: nowrap; text-overflow: ellipsis; } .when { grid-area: when; color: var(--muted); font-size: calc(11.5px * var(--ui-scale)); font-variant-numeric: tabular-nums; } .where { grid-area: where; overflow: hidden; color: var(--muted); font-size: calc(12px * var(--ui-scale)); white-space: nowrap; text-overflow: ellipsis; } .none { margin: 16px 8px; color: var(--muted); font-size: calc(12.5px * var(--ui-scale)); } .ghost { height: 38px; margin: 2px 8px 6px; background: var(--hover); border-radius: 7px; }</style>