char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
b4fc6cf
main
1import { Signal } from "@char/aftercare" ; 2import { 3type Project , 4projectFor , 5type ProjectIndex , 6type ProjectInput , 7type Workspace , 8workspaceFor , 9} from "@berry/api/projects.ts" ; 10import { api } from "./client.ts" ; 11import { configurationField } from "./configuration.tsx" ; 12import { folderInput } from "./folders.tsx" ; 13import { Scope } from "./ui.ts" ; 14 15const workspaceLabel = ( root :{ environment :string ; cwd :string }) => 16` ${ root . environment === "local" ? "" : ` ${ root . environment } · ` } ${ root . cwd || "home" } ` ; 17 18/** The sidebar's groups in order, `""` being no project; those never ordered follow by name. */ 19export function sidebarOrder ( projects :ProjectIndex , order :readonly string []) :string [] { 20const named = Object . keys ( projects ). sort (( a , b ) => 21projects [ a ]. name . localeCompare ( projects [ b ]. name ) 22); 23return [ ...new Set ([ ...order , ...named , "" ])]. filter (( id ) => id === "" || id in projects ); 24} 25 26/** Move a sidebar group to `index`, saving the new order. */ 27export async function moveGroup ( 28projects :ProjectIndex , 29order :Signal < readonly string []>, 30id :string , 31index :number , 32) { 33const ids = sidebarOrder ( projects , order . get ()). filter (( other ) => other !== id ); 34ids . splice ( index , 0 , id ); 35await api ( "PUT" , "/api/project-order" , { ids}); 36order . set ( ids ); 37} 38 39export function projectPicker ( 40scope :Scope , 41projects :Signal < ProjectIndex >, 42choose :( workspace :Workspace | null ) => void , 43) { 44const select = < select aria-label = "project" /> as HTMLSelectElement ; 45const workspace = < select aria-label = "workspace" /> as HTMLSelectElement ; 46const workspaceField = ( 47< label class = "field" hidden > 48< span > workspace </ span > 49{ workspace} 50< / label> 51) as HTMLLabelElement ; 52const element = ( 53< div class = "project-picker" > 54< label class = "field" > 55< span > project </ span > 56{ select } 57< /label> 58{ workspaceField } 59< / div> 60) as HTMLElement ; 61const draw = ( reset = false ) => { 62const previous = workspace . selectedOptions [ 0 ]?. dataset . key ; 63const selected = select . value ; 64const index = projects . get (); 65select . replaceChildren ( 66< option value = "" > no project </ option >, 67... Object . entries ( index ) 68. sort (([, a ], [, b ]) => a . name . localeCompare ( b . name )) 69. map (([ id , project ]) => < option value = { id }>{ project . name }</ option >), 70); 71select . value = index [ selected ] ? selected : "" ; 72const project = index [ select . value ]; 73const key = ( root : Workspace ) => ` ${ root . environment } \n ${ root . cwd } ` ; 74workspace . replaceChildren ( 75... ( project ?. roots ?? []). map (( root , i ) => ( 76< option value = { String ( i )} data-key = { key ( root )}>{ workspaceLabel ( root )}</ option > 77)), 78); 79const previousIndex = reset 80? - 1 81: project ?. roots . findIndex (( root ) => key ( root ) === previous ) ?? - 1 ; 82workspace . value = String ( previousIndex >= 0 ? previousIndex : project ?. defaultRoot ?? 0 ); 83workspaceField . hidden = ! project || project . roots . length < 2 ; 84element . hidden = Object . keys ( index ). length === 0 ; 85}; 86const pick = ( id : string ) => { 87select . value = id ; 88draw ( true ); 89const project = projects . get ()[ select . value ]; 90choose ( project ?. roots [ project . defaultRoot ] ?? null ); 91}; 92select . addEventListener ( "change" , () => pick ( select . value )); 93workspace . addEventListener ( "change" , () => { 94const root = projects . get ()[ select . value ]?. roots [ Number ( workspace . value )]; 95if ( root ) choose ( root ); 96}); 97scope . add ( projects . subscribe (() => draw ()). unsubscribe ); 98draw (); 99return { 100element , 101pick , 102/** Show the project, and its workspace, that a place chosen by hand is in, without choosing it again. */ 103show ( place : { environment : string ; cwd : string }) { 104const id = projectFor ( projects . get (), place ) ?? "" ; 105if ( select . value !== id ) { 106select . value = id ; 107draw ( true ); 108} 109const project = projects . get ()[ id ]; 110const root = project && workspaceFor ( project , place ); 111if ( root ) workspace . value = String ( project . roots . indexOf ( root )); 112}, 113}; 114} 115 116export function projectEditor ( 117scope : Scope , 118machines : Signal < readonly string []>, 119projects : Signal < ProjectIndex >, 120order = new Signal < readonly string []>([]), 121) { 122const title = < h2 id = "project-editor-title" /> as HTMLElement ; 123const name = < input required maxLength = { 200 } /> as HTMLInputElement ; 124const roots = < div class = "project-roots" / > as HTMLElement ; 125const status = < p role = "status" class = "error" / > as HTMLElement ; 126const remove = ( 127< button type = "button" class = "link danger project-delete" > 128delete project 129< / button> 130) as HTMLButtonElement ; 131const move = < input type = "checkbox" /> as HTMLInputElement ; 132const moveField = ( 133< label class = "field" hidden > 134< span >{ move } move conversations from removed workspaces to the default workspace </ span > 135< / label> 136) as HTMLLabelElement ; 137let original : Project | undefined ; 138let id : string | undefined ; 139let draft : ProjectInput = { name : "" , roots : [], defaultRoot : 0 }; 140let fields = new Scope (); 141let pending = false ; 142const updateRemoval = () => { 143moveField . hidden = ! original ?. roots . some (( root ) => 144! draft . roots . some (( saved ) => saved . environment === root . environment && saved . cwd === root . cwd ) 145); 146}; 147const draw = () => { 148fields . dispose (); 149fields = new Scope (); 150roots . replaceChildren ( ... draft . roots . map (( root , i ) => { 151const environment = new Signal ( root . environment ); 152const folder = folderInput ({ scope : fields , environment , projects , base : () => null }); 153folder . input . value = root . cwd ; 154folder . input . setAttribute ( "aria-label" , `folder of workspace ${ i + 1 } ` ); 155folder . input . addEventListener ( "input" , () => { 156root . cwd = folder . input . value ; 157updateRemoval (); 158}); 159const machine = configurationField ( 160"environment" , 161{ type : "string" }, 162root . environment , 163( value ) => { 164root . environment = String ( value ); 165root . cwd = folder . input . value = "" ; 166environment . set ( root . environment ); 167updateRemoval (); 168}, 169{ scope : fields , choices : machines }, 170). input ; 171machine . setAttribute ( "aria-label" , `environment of workspace ${ i + 1 } ` ); 172return ( 173< div class = "project-root" role = "group" aria - label = { `workspace ${ i + 1 } ` }> 174< input 175type = "radio" 176name = "default-workspace" 177title = "default directory for new conversations" 178aria-label = { `start new conversations in workspace ${ i + 1 } ` } 179checked = { draft . defaultRoot === i } 180_onchange = {() => draft . defaultRoot = i } 181/> 182{ machine } 183{ folder . element } 184< button 185type = "button" 186class = "link danger" 187title = "remove workspace" 188aria - label = { `remove workspace ${ i + 1 } ` } 189disabled = { draft . roots . length === 1 } 190_onclick = {() = > { 191draft . roots . splice ( i , 1 ); 192if ( draft . defaultRoot === i ) draft . defaultRoot = 0 ; 193else if ( draft . defaultRoot > i ) draft . defaultRoot -- ; 194draw (); 195}} 196> 197× 198< /button> 199</ div > 200); 201})); 202updateRemoval (); 203}; 204const close = () => { 205if ( pending ) return ; 206dialog . close (); 207fields . dispose (); 208}; 209const up = ( 210< button type = "button" class = "link" aria - label = "move up" > 211↑ 212< / button> 213) as HTMLButtonElement ; 214const down = ( 215< button type = "button" class = "link" aria - label = "move down" > 216↓ 217< / button> 218) as HTMLButtonElement ; 219// reordering saves at once, so it sits apart from the fields that wait for save 220const ordering = < div class = "project-order" >{ up }{ down }< /div> as HTMLElement; 221const drawOrder = () => { 222const ids = sidebarOrder ( projects . get (), order . get ()); 223ordering . hidden = id === undefined ; 224up . disabled = ids [ 0 ] === id ; 225down . disabled = ids . at ( - 1 ) === id ; 226}; 227for ( const [ button , delta ] of [[ up , - 1 ], [ down , 1 ]] as const ) { 228button . addEventListener ( "click" , async () => { 229if ( pending || id === undefined ) return ; 230pending = true ; 231status . textContent = "" ; 232try { 233const at = sidebarOrder ( projects . get (), order . get ()). indexOf ( id ); 234await moveGroup ( projects . get (), order , id , at + delta ); 235} catch ( error ) { 236status . textContent = ( error as Error ). message ; 237} finally { 238pending = false ; 239drawOrder (); 240} 241}); 242} 243const details = ( 244< div class = "project-details" > 245< label class = "field" > 246< span > name </ span > 247{ name } 248< / label> 249< div class = "field" > 250< span > workspaces </ span > 251{ roots } 252< button 253type = "button" 254class = "link" 255_onclick = {() => { 256draft . roots . push ({ environment : "local" , cwd : "" }); 257draw (); 258roots . querySelector < HTMLInputElement >( ".project-root:last-child [role=combobox]" ) 259?. focus (); 260}} 261> 262add workspace 263< /button> 264</ div > 265{ moveField } 266< /div> 267) as HTMLElement ; 268const dismiss = < button type = "button" _onclick = { close }> cancel </ button > as HTMLButtonElement ; 269const save = < button type = "submit" class = "primary" > save < /button> as HTMLButtonElement; 270const controls = ( 271< fieldset class = "project-controls" > 272{ details } 273{ status } 274< div class = "dialog-actions" > 275{ remove } 276{ dismiss } 277{ save } 278< /div> 279</ fieldset > 280) as HTMLFieldSetElement ; 281const dialog = ( 282< dialog class = "dialog project-dialog" aria - labelledby = "project-editor-title" > 283< form 284_onsubmit = { async ( event ) => { 285event . preventDefault (); 286if ( pending ) return ; 287pending = true ; 288controls . disabled = true ; 289status . textContent = "" ; 290try { 291await api ( id ? "PUT" : "POST" , id ? `/api/projects/ ${ id } ` : "/api/projects" , { 292... draft , 293name : name . value , 294... ( ! moveField . hidden && move . checked ? { moveConversations : true } : {}), 295}); 296pending = false ; 297close (); 298} catch ( error ) { 299status . textContent = ( error as Error ). message ; 300} finally { 301pending = false ; 302controls . disabled = false ; 303} 304}} 305> 306< header class =" project-dialog-header " > 307{ title } 308{ ordering } 309</ header > 310{ controls } 311</ form > 312< / dialog> 313) as HTMLDialogElement ; 314dialog . addEventListener ( "cancel" , ( event ) => { 315if ( pending ) event . preventDefault (); 316}); 317dialog . addEventListener ( "close" , () => fields . dispose ()); 318remove . addEventListener ( "click" , async () => { 319if ( 320! id || pending || ! confirm ( "delete this project? conversations will move to 'no project'" ) 321) { 322return ; 323} 324pending = true ; 325controls . disabled = true ; 326try { 327await api ( "DELETE" , `/api/projects/ ${ id } ` ); 328pending = false ; 329close (); 330} catch ( error ) { 331status . textContent = ( error as Error ). message ; 332} finally { 333pending = false ; 334controls . disabled = false ; 335} 336}); 337scope . add (() => { 338fields . dispose (); 339dialog . remove (); 340}); 341return { 342element : dialog , 343open ( projectId ?: string ) { 344id = projectId ; 345const project : Project | undefined = id ? projects . get ()[ id ] : undefined ; 346original = project ; 347move . checked = false ; 348title . textContent = id === "" ? "no project" : project ? "edit project" : "new project" ; 349details . hidden = save . hidden = id === "" ; 350dismiss . textContent = id === "" ? "done" : "cancel" ; 351name . value = project ?. name ?? "" ; 352draft = { 353name : name . value , 354roots : project ?. roots . map (({ environment , cwd }) => ({ environment , cwd })) ?? 355[{ environment : "local" , cwd : "" }], 356defaultRoot : project ?. defaultRoot ?? 0 , 357}; 358status . textContent = "" ; 359remove . hidden = ! project ; 360draw (); 361drawOrder (); 362dialog . showModal (); 363if ( id !== "" ) name . focus (); 364}, 365}; 366}