Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209<script lang="ts"> import type { ArtifactRequestRecord, GoalView, IndexedRecord } from '@radial/core' import { participates } from '$lib/admin.js' import { account } from '$lib/auth.svelte.js' import { relativeTime, stamp } from '$lib/format.js' import { inline } from '$lib/prose.js' import { retractArgs, retractable } from '$lib/requests.js' import { answerState, filterThreadRows, messageSearchText, pendingAnswers, pendingFor, threadMessageCount, threadRows, type ThreadMessage, } from '$lib/replies.js' import type { Space } from '$lib/space.js' import { filtering, matches, toast } from '$lib/ui.svelte.js' import { write } from '$lib/write.js' import AskAnswer from './AskAnswer.svelte' import Badge from './Badge.svelte' import Runs from './Runs.svelte' import Disc from './Disc.svelte' import StateDot from './StateDot.svelte' import ModelLabel from './ModelLabel.svelte'
// The goal's thread. Messages are agent-authored too, so they render through the same // interpolation path as artifact bodies — line breaks preserved, backticks marked, no markup. // // It reads as an exchange rather than a list: a reply sits one level in, behind a hairline, under // what it answers. One level and no further — a deeper chain flattens onto the same row with an "in // reply to" line, because a conversation rendered as a tree is one nobody reads (DESIGN.md, Measure). interface Props { view: GoalView space: Space /** Point the composer below at a message. Absent in read-only surfaces. */ onReply?: ((message: ThreadMessage) => void) | undefined } const { view, space, onReply }: Props = $props()
// Quick find narrows the exchange too — a long thread is exactly where looking for one thing said // once is worth doing. What it does NOT touch is the composer below it or the actions on each row: // a filter is a way of reading, and a reader who searches must still be able to answer what they // found. const rows = $derived( filterThreadRows(threadRows(view), (message) => !filtering() || matches(messageSearchText(message, space.directory)), ), ) // What is on screen, which with no query is every message the view holds. const count = $derived(threadMessageCount(rows)) const awaiting = $derived(new Set(view.awaitingInput)) const pending = $derived(pendingAnswers(view)) // Requests that are conversation rather than work. They are deliberately not units, so they have // no row anywhere — which decides how a question from one is drawn below. const answerRequests = $derived( new Set( view.requests.filter((request) => request.value.type === 'answer').map((request) => request.uri), ), )
/** An unanswered question from a turn: what the fold is blocked on. */ const blocking = (message: ThreadMessage): boolean => Boolean(message.value.re && awaiting.has(message.value.re.uri)) /** * …and whether the row it came from already shows it. A plan or review turn's question is drawn in * full on the request's own row, so repeating it here would put the same block on screen twice. An * ANSWER turn's question has no row to be shown on — an answer request is never a unit — so it is * shown here, which is also the only place it can be replied to. */ const shownOnItsRow = (message: ThreadMessage): boolean => blocking(message) && !answerRequests.has(message.value.re?.uri ?? '')
// One card at a time, keyed by the message it is opened on. let asking = $state('') let retracting = $state('') let error = $state('')
const writable = $derived( !space.fixture && account.status === 'signed-in' && participates(space.directory, account.did), )
async function retract(request: IndexedRecord<ArtifactRequestRecord>): Promise<void> { if (retracting) return retracting = request.uri error = '' try { await write(retractArgs(request)) toast('Retract tombstone written — nothing was deleted') } catch (failure) { error = failure instanceof Error ? failure.message : String(failure) } finally { retracting = '' } }</script>
{#snippet body(message: ThreadMessage)} {#if shownOnItsRow(message)} <p class="mb" style="color:var(--ink-2)">Asked a question on the request above — shown on that row.</p> {:else} <!-- A message keeps its own line breaks (the block is `pre-wrap`), so it is one flat pass of plain and backticked runs rather than paragraphs. --> <p class="mb"><Runs runs={inline(message.value.body)} /></p> {/if}{/snippet}
{#snippet actions(message: ThreadMessage)} {#if writable} <div class="msg-acts"> {#if onReply} <button class="linkish" type="button" onclick={() => onReply(message)}>Reply</button> {/if} <button class="linkish" type="button" onclick={() => (asking = asking === message.uri ? '' : message.uri)} > {asking === message.uri ? 'Close' : 'Ask an agent to reply'} </button> </div> {#if asking === message.uri} <AskAnswer {view} {space} {message} done={() => (asking = '')} /> {/if} {/if} {#each pendingFor(pending, message) as request (request.uri)} {@const who = request.value.assignee ? space.directory.get(request.value.assignee) : undefined} <!-- A request somebody owes, read straight off the fold. It disappears when the reply lands, the same way a review request disappears when a verdict does. --> <div class="pending"> <StateDot state={answerState(view, request)} /> <span> asked <b>{who?.handle ?? who?.name ?? 'an agent'}</b> to reply · {relativeTime( request.value.createdAt, space.asOf, )} {#if answerState(view, request) === 'awaiting'}— it asked a question back{/if} </span> <!-- Only from someone whose tombstone the fold would honour: its author, or an active admin. Anyone else pressing it would get a success toast on a row that never moves. --> {#if writable && retractable(request, space.directory, account.did)} <button class="linkish" type="button" disabled={retracting === request.uri} onclick={() => retract(request)} > {retracting === request.uri ? 'Retracting…' : 'Retract'} </button> {/if} </div> {/each}{/snippet}
<div class="sec-head">Thread {#if count}<span class="n">{count}</span>{/if}</div>{#if rows.length === 0} <div class="empty"> <p>{filtering() ? 'No message in this thread matches that.' : 'Nothing has been said on this goal yet.'}</p> </div>{:else} <div> {#each rows as row (row.message.uri)} {@const actor = space.directory.get(row.message.did)} {@const question = blocking(row.message)} <div class="msg"> <Disc {actor} /> <div class="mh"> <b>{actor.name}</b> <ModelLabel model={row.message.value.model} /> <span class="when">{stamp(row.message.value.createdAt)}</span> {#if question} <Badge badge={{ kind: 'warn', glyph: '◉', text: 'declined — needs input' }} /> {/if} </div> {@render body(row.message)} {@render actions(row.message)} </div> {#if row.replies.length > 0} <div class="replies"> {#each row.replies as reply (reply.message.uri)} {@const author = space.directory.get(reply.message.did)} <div class="msg"> <Disc actor={author} /> <div class="mh"> <b>{author.name}</b> <ModelLabel model={reply.message.value.model} /> <span class="when">{stamp(reply.message.value.createdAt)}</span> {#if reply.inReplyTo} <span class="when"> in reply to {space.directory.get(reply.inReplyTo.did).name} </span> {/if} </div> {@render body(reply.message)} {@render actions(reply.message)} </div> {/each} </div> {/if} {/each} </div>{/if}{#if error} <p class="write-error" role="alert">{error}</p>{/if}