char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
d17332c
main
1import type { InboxItem } from "@earendil-works/pi-durable" ; 2import { imageSummary } from "@berry/api/images.ts" ; 3import { api } from "./client.ts" ; 4import { json , keyed , type Scope } from "./ui.ts" ; 5import { event } from "./event.tsx" ; 6 7// the order the harness places them in: every write at the next boundary, a steer at each boundary from there, and 8// the follow-ups once the run ends, each in the order they were queued 9const PLACED :Record < InboxItem [ "mode" ], number > = { write :0 , steer :1 , followUp :2 }; 10 11/** Queued submissions retain their editors until the submission leaves the inbox. */ 12export function inbox ( scope :Scope , tid :string ) { 13const element = < div class = "inbox" / > as HTMLElement ; 14const rows = keyed ( element , ( item :InboxItem ) => item . id , ( initial ) => { 15const mode = ( 16< span class = "mode" > 17{ initial . mode === "write" 18 ?"pending" 19 :initial . mode === "followUp" 20 ?"follow-up" 21 :initial . mode } 22</ span > 23); 24if ( initial . mode === "write" ) { 25const content = initial . entry . kind === "berry.event" ? event ( initial . entry . data ) : ( 26< details > 27< summary >{ typeof initial . entry . kind === "string" ? initial . entry . kind : "event" }</ summary > 28< pre >{ json ( initial . entry )}</ pre > 29</ details > 30); 31return { 32node : < div class = "queued" hidden = { content === null }>{ mode } { content ?? undefined }< / div>, 33}; 34} 35 36let item = initial ; 37let editing = false ; 38let pending = false ; 39let disposed = false ; 40const content = < span class = "queued-content" / > as HTMLElement ; 41const images = < span class = "queued-images" / > as HTMLElement ; 42const error = < div class = "queued-error" role = "alert" hidden / > as HTMLElement; 43const textarea = < textarea aria-label = "Queued message text" rows = { 3 } /> as HTMLTextAreaElement ; 44const edit = < button type = "button" > Edit </ button > as HTMLButtonElement ; 45const retract = < button type = "button" class = "danger" > Retract < / button> as HTMLButtonElement; 46const save = < button type = "submit" class = "primary" > Save < / button> as HTMLButtonElement; 47const cancel = < button type = "button" > Cancel </ button > as HTMLButtonElement ; 48const actions = < div class = "queued-actions" >{ edit }{ retract }< / div> as HTMLElement; 49const form = ( 50< form class = "queued-editor" hidden > 51{ textarea } 52< div class = "queued-actions" >{ save }{ cancel }< / div> 53</ form > 54) as HTMLFormElement ; 55const node = ( 56< div class = "queued" > 57< div >{ mode } { content } { images }</ div > 58{ actions } 59{ form } 60{ error } 61< / div> 62); 63const text = () => 64typeof item . content === "string" 65? item . content 66: item . content . flatMap (( block ) => block . type === "text" ? [ block . text ] : []). join ( "\n" ); 67const render = () => { 68content . textContent = text (); 69content . hidden = editing ; 70images . textContent = imageSummary ( item . content ); 71actions . hidden = editing ; 72form . hidden = ! editing ; 73textarea . readOnly = pending ; 74for ( const button of [ edit , retract , save , cancel ]) button . disabled = pending ; 75save . disabled ||= ! textarea . value . trim () && ! imageSummary ( item . content ); 76}; 77const mutate = async ( method : "PATCH" | "DELETE" ) => { 78if ( disposed || pending ) return ; 79if ( method === "PATCH" && ! textarea . value . trim () && ! imageSummary ( item . content )) return ; 80pending = true ; 81error . hidden = true ; 82render (); 83try { 84await api ( 85method , 86`/api/conversations/ ${ tid } /inbox/ ${ item . id } ` , 87method === "PATCH" ? { text : textarea . value } : undefined , 88); 89if ( disposed ) return ; 90// snapshots own content and removal; a late response must not restore a consumed submission 91editing = false ; 92} catch ( cause ) { 93if ( disposed ) return ; 94error . textContent = cause instanceof Error ? cause . message : String ( cause ); 95error . hidden = false ; 96} finally { 97if ( ! disposed ) { 98pending = false ; 99render (); 100} 101} 102}; 103edit . addEventListener ( "click" , () => { 104if ( pending || disposed ) return ; 105editing = true ; 106textarea . value = text (); 107error . hidden = true ; 108render (); 109textarea . focus (); 110}); 111cancel . addEventListener ( "click" , () => { 112if ( pending || disposed ) return ; 113editing = false ; 114textarea . value = text (); 115error . hidden = true ; 116render (); 117edit . focus (); 118}); 119textarea . addEventListener ( "input" , render ); 120form . addEventListener ( "submit" , ( event ) => { 121event . preventDefault (); 122void mutate ( "PATCH" ); 123}); 124retract . addEventListener ( "click" , () => void mutate ( "DELETE" )); 125render (); 126return { 127node , 128update ( next : InboxItem ) { 129if ( next . mode === "write" ) return ; 130item = next ; 131render (); 132}, 133dispose () { 134disposed = true ; 135}, 136}; 137}); 138scope . add ( rows . dispose ); 139return { 140element , 141update : ( items : readonly InboxItem []) => 142rows . sync ([ ... items ]. sort (( a , b ) => PLACED [ a . mode ] - PLACED [ b . mode ])), 143}; 144}