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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226<script lang="ts"> import type { SelectedLineRange } from '@pierre/diffs'; import { tick } from 'svelte'; import { diffKey, diffs, quoteLines } from '../anchors'; import { sendMessage, uuidv7 } from '../api'; import type { ReviewWindow, WindowProps } from '../board.svelte'; import { boards } from '../boards.svelte'; import { sendNotes } from '../notes'; import type { FileChanges } from '../review'; import { sessions } from '../sessions.svelte'; import ReviewView from '../views/ReviewView.svelte'; import WindowFrame from './WindowFrame.svelte';
// A turn's review as a window on the canvas. The margin beside any line // (drag for several) leaves a note out on the canvas beside the window, // tied to those lines; "send review" sends every note tied into this // review as one message. let { id, data, selected }: WindowProps<ReviewWindow> = $props(); const session = $derived(sessions.byId.get(data.sessionId));
let files = $state<FileChanges[] | null>(null); let body = $state<HTMLElement>(); let node = $state<HTMLElement>(); let summary = $state(''); let sending = $state(false); let problem = $state(''); let pinned = false; let lastTop = 0; // Where the last press in the window was, so a note lands level with the line it came from. let pointerY = 0; const hasFiles = $derived(!!files?.length);
const notes = $derived(boards.board?.notesOn(id) ?? []); const waiting = $derived(notes.filter((note) => !note.data.sent && note.data.text.trim())); const added = $derived(files?.reduce((sum, file) => sum + file.added, 0) ?? 0); const removed = $derived(files?.reduce((sum, file) => sum + file.removed, 0) ?? 0);
// Scrolls only the body: scrollIntoView would also scroll the canvas // behind the window, which is not meant to scroll at all. function scroll(target: HTMLElement) { if (!body) return; const scale = body.clientHeight ? body.getBoundingClientRect().height / body.clientHeight : (boards.board?.zoomOf(id) ?? 1); const top = body.scrollTop + (target.getBoundingClientRect().top - body.getBoundingClientRect().top) / scale - 8; pinned = body.scrollHeight - top - body.clientHeight < 40; body.scrollTop = top; }
function scrolled() { if (!body) return; if (body.scrollHeight - body.scrollTop - body.clientHeight < 40) pinned = true; else if (body.scrollTop < lastTop) pinned = false; lastTop = body.scrollTop; }
function toBottom() { if (!body) return; body.scrollTop = body.scrollHeight; lastTop = body.scrollTop; }
// Stay at the bottom as new changes arrive, while the reader is at the end. $effect(() => { data.sessionId; data.turnId; const content = body?.firstElementChild; if (!body || !content || !hasFiles) return; let settled = false; let lastHeight = content.clientHeight; const observer = new ResizeObserver(() => { if (!body) return; const height = content.clientHeight; if (!settled) { settled = true; pinned = body.scrollHeight - body.scrollTop - body.clientHeight < 40; lastTop = body.scrollTop; } else if (pinned && height > lastHeight) { toBottom(); } lastHeight = height; }); observer.observe(content); return () => observer.disconnect(); });
/** Leaves a note beside the window, level with the lines it is about. */ function comment(path: string, change: number, range: SelectedLineRange) { const board = boards.board; const known = diffs.get(diffKey(id, path, change)); if (!board || !node || !known) return; const side = range.endSide ?? range.side ?? 'additions'; const [start, end] = range.start <= range.end ? [range.start, range.end] : [range.end, range.start]; board.noteBeside({ kind: 'lines', window: id, path, change, side, start, end, quote: quoteLines(known.diff, side, start, end) }, pointerY - 16); }
async function send() { const board = boards.board; if (!board || (!waiting.length && !summary.trim())) return; sending = true; problem = ''; try { if (waiting.length) await sendNotes(board, waiting, `Review of your changes:${summary.trim() ? `\n\n${summary.trim()}` : ''}`); else await sendMessage(data.sessionId, uuidv7(), `Review of your changes:\n\n${summary.trim()}`); summary = ''; } catch (failure) { problem = String((failure as Error).message ?? failure); } finally { sending = false; } }
function count(path: string) { return notes.filter((note) => note.data.target?.kind === 'lines' && note.data.target.path === path && !note.data.sent).length; }</script>
<div class="review-node" bind:this={node} onpointerdowncapture={(event) => (pointerY = event.clientY)}><WindowFrame {id} selected={!!selected} title="review · {session?.name || 'untitled session'}"> {#snippet meta()} {#if files} <span>{files.length} {files.length === 1 ? 'file' : 'files'}</span> <span class="counts"><span class="added">+{added}</span> <span class="removed">−{removed}</span></span> {/if} {/snippet}
<div class="review nowheel" bind:this={body} onscroll={scrolled}> <ReviewView sessionId={data.sessionId} turnId={data.turnId} from={data.from} workspace={session?.workspace ?? ''} jump={data.jump} keyOf={(path, change) => diffKey(id, path, change)} {scroll} oncomment={comment} {count} onfiles={(found) => { const had = !!files?.length; files = found; if (had && pinned) tick().then(toBottom); }} /> </div> {#if files?.length} <footer class="send"> <textarea bind:value={summary} rows="1" placeholder={waiting.length ? 'overall, optional' : 'overall, or + beside a line to leave a note'} onkeydown={(event) => event.key === 'Escape' && event.currentTarget.blur()}></textarea> {#if problem}<span class="problem" title={problem}>not sent</span>{/if} <button disabled={sending || (!waiting.length && !summary.trim())} onclick={send}> send review{waiting.length ? ` · ${waiting.length}` : ''} </button> </footer> {/if}</WindowFrame>
</div>
<style> .review-node { position: relative; display: flex; width: 100%; height: 100%; } .review { flex: 1; min-height: 0; padding: 14px 16px 24px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } .counts { font-variant-numeric: tabular-nums; } .added { color: var(--added); } .removed { color: var(--danger); } .send { display: flex; align-items: flex-end; gap: 8px; padding: 10px 12px; background: var(--title-bar); } .send textarea { flex: 1; min-height: 30px; max-height: 140px; padding: 6px 10px; color: var(--text); font: inherit; font-size: calc(13px * var(--canvas-scale)); background: var(--field); border: 0; border-radius: 8px; outline: 0; resize: none; field-sizing: content; } .send button { height: 30px; padding: 0 12px; color: var(--surface); font: inherit; font-size: calc(12.5px * var(--canvas-scale)); font-weight: 560; white-space: nowrap; background: var(--accent); border: 0; border-radius: 8px; cursor: pointer; } .send button:disabled { opacity: 0.35; cursor: default; } .problem { align-self: center; color: var(--danger); font-size: calc(11.5px * var(--canvas-scale)); }</style>