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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184<script lang="ts"> import type { NodeProps } from '@xyflow/svelte'; import type { ChipKind, SessionWindow } from '../board.svelte'; import Chat from '../chat/Chat.svelte'; import ModelPicker from '../chat/ModelPicker.svelte'; import { place } from '../format'; import { boards } from '../boards.svelte'; import { busy } from '../live.svelte'; import AgentsButton from '../AgentsButton.svelte'; import ArtifactsButton from '../ArtifactsButton.svelte'; import JobsButton from '../JobsButton.svelte'; import { sessions } from '../sessions.svelte'; import WindowFrame from './WindowFrame.svelte';
let { id, data, selected }: NodeProps<SessionWindow> = $props(); const session = $derived(sessions.byId.get(data.sessionId)); const working = $derived(busy(session)); const rootId = $derived(session?.root_id ?? data.sessionId); const reading = $derived(sessions.readings.get(data.sessionId)); const docked = (chip: ChipKind) => !boards.board?.hasChip(id, chip);
$effect(() => { const sid = data.sessionId; return () => sessions.clearReading(sid); });
// Dragging a chip >10px out of the title bar tears it off into a live tethered // window that follows the pointer across the canvas; a plain click still opens its menu. function grabChip(chip: ChipKind, event: PointerEvent) { if (event.button !== 0 || event.altKey) return; const target = event.target as HTMLElement | null; if (!target || target.closest('.pane, .list, .tip')) return; const pointer = event.pointerId; const startX = event.clientX; const startY = event.clientY; const sessionId = data.sessionId; const parentId = id; let torn = false;
const move = (moved: PointerEvent) => { if (moved.pointerId !== pointer) return; if (!torn && Math.hypot(moved.clientX - startX, moved.clientY - startY) > 10) { torn = true; } if (torn) { boards.board?.undockChip(sessionId, chip, parentId, { x: moved.clientX, y: moved.clientY }); } };
const cleanup = () => { document.removeEventListener('pointermove', move, true); document.removeEventListener('pointerup', up, true); document.removeEventListener('pointercancel', cancel, true); };
const up = (ended: PointerEvent) => { if (ended.pointerId !== pointer) return; cleanup(); if (!torn) return; const swallow = (click: MouseEvent) => { click.preventDefault(); click.stopPropagation(); }; window.addEventListener('click', swallow, true); setTimeout(() => window.removeEventListener('click', swallow, true), 0); boards.board?.undockChip(sessionId, chip, parentId, { x: ended.clientX, y: ended.clientY }); };
const cancel = (cancelled: PointerEvent) => { if (cancelled.pointerId !== pointer) return; cleanup(); };
document.addEventListener('pointermove', move, true); document.addEventListener('pointerup', up, true); document.addEventListener('pointercancel', cancel, true); }
// An agent's window is not in the list of sessions a person started, so its // record is read when the board opens it again. let missing = $state(false); $effect(() => { if (session) missing = false; else if (sessions.loaded) sessions.ensure(data.sessionId).then((found) => (missing = !found)); });</script>
<WindowFrame {id} selected={!!selected} title={session?.name || 'untitled session'}> {#snippet meta()} {#if session && docked('model')} <span class="chip-slot nodrag" role="presentation" onpointerdown={(e) => grabChip('model', e)}> <ModelPicker {session} usage={reading?.usage ?? null} entries={reading?.entries ?? []} down /> </span> {/if} {#if docked('artifacts')} <span class="chip-slot nodrag" role="presentation" onpointerdown={(e) => grabChip('artifacts', e)}> <ArtifactsButton sessionId={data.sessionId} busy={working} onopen={(artifact) => boards.board?.openArtifact(artifact.id, data.sessionId, artifact.version, id)} /> </span> {/if} {#if docked('agents')} <span class="chip-slot nodrag" role="presentation" onpointerdown={(e) => grabChip('agents', e)}> <AgentsButton sessionId={data.sessionId} {rootId} busy={working} onopen={(agent) => (sessions.adopt(agent), boards.board?.openSession(agent.id, id))} ontree={() => boards.board?.openAgents(rootId, id)} /> </span> {/if} {#if docked('jobs')} <span class="chip-slot nodrag" role="presentation" onpointerdown={(e) => grabChip('jobs', e)}> <JobsButton sessionId={data.sessionId} onopen={(job) => boards.board?.openTerminal(data.sessionId, job, id)} /> </span> {/if} {#if session} <button class="terminal nodrag" aria-label="Open a terminal in {place(session.workspace)}" title="Open a terminal in {session.workspace}" onclick={() => boards.board?.openPty({ cwd: session.workspace, sessionId: data.sessionId, parent: id })}> <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> <span class="place" title={session.workspace}><span class="place-text">{place(session.workspace)}</span></span> {/if} {/snippet}
{#if session || !missing} <Chat sessionId={data.sessionId} {session} windowId={id} onusage={(next, items) => sessions.recordReading(data.sessionId, next, items)} /> {:else} <p class="gone">(・_・;) this session is no longer on the daemon</p> {/if}</WindowFrame>
<style> .chip-slot { display: contents; }
/* As the menu icons beside it (MenuButton's .icon). */ .terminal { 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; } .terminal:hover { color: var(--text); background: var(--field); } .place { display: inline-flex; flex: 0 1 auto; min-width: 48px; max-width: 160px; align-items: center; height: 26px; padding: 0 9px; color: var(--muted); font-size: calc(11.5px * var(--ui-scale)); line-height: 1; background: var(--well); border-radius: 8px; } .place-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .gone { margin: 20px; color: var(--muted); }</style>