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 { projectFor , type ProjectIndex } from "@berry/api/projects.ts" ; 3import { moveGroup , projectEditor , sidebarOrder } from "./projects.tsx" ; 4import { api , connected , watch } from "./client.ts" ; 5import { conversationName } from "./conversation-name.ts" ; 6import { conversation , draft , type ExtensionInfo , type Shared } from "./conversation.tsx" ; 7import { newConversation } from "./new-conversation.tsx" ; 8import { type ModelInfo , type ModelRef , settings } from "./settings.tsx" ; 9import { syncRenderers } from "./renderers.ts" ; 10import { computed , type Handle , keyed , relativeTime , Scope , swap , tidDate } from "./ui.ts" ; 11import { announcements } from "./notifications.ts" ; 12import { beforeNavigate } from "./navigation.ts" ; 13 14type ConversationInfo = { 15id :number ; 16created :number ; 17title ?:string ; 18parent ?:{ tid :string ; at :number }; 19owner ?:string ; 20branches ?:{ id :number ; at :number ; created :number }[]; 21head ?:number ; 22archived ?:number ; 23}; 24type Item = { 25tid :string ; 26title ?:string ; 27depth :number ; 28forks :number ; 29agents :number ; 30/** Whether this row is a background subagent, which reads as delegated rather than forked. */ 31subagent :boolean ; 32open :boolean ; 33archived :boolean ; 34parentLink ?:string ; 35}; 36type Summary = { 37busy :boolean ; 38preview :string ; 39reply ?:string ; 40unread :boolean ; 41updated :number ; 42environment ?:string ; 43cwd ?:string ; 44}; 45 46const EMPTY :readonly never [] = []; 47/** A row of the sidebar: a conversation, where it sits in the list, and how it is doing. */ 48type Row = Item & { summary :Summary | null ; active :boolean }; 49 50function row ( first :Row , toggle :( tid :string ) => void ) :Handle < Row > { 51const { tid} = first ; 52let shown :Row | null = null ; 53const chevron = ( 54< button 55type = "button" 56class = "chevron" 57_onclick = {() => toggle ( tid )} 58/ > 59) as HTMLButtonElement ; 60const preview = < span title = "Double-click to rename" /> as HTMLElement ; 61const forks = < span class = "forks" / > as HTMLElement ; 62const busy = < span /> as HTMLElement ; 63const time = < time /> as HTMLTimeElement ; 64const parentLink = ( 65< a class = "parent-link" aria - label = "Parent conversation" title = "parent conversation" hidden > ↖ < / a> 66) as HTMLAnchorElement ; 67const link = ( 68< a href = { `#/ ${ tid } ` } title = { tid }> 69{ first . subagent 70? < span class = "delegated" role = "img" aria - label = "subagent" title = "subagent" > ↳ < / span> 71: undefined } 72{ preview } 73{ forks } 74{ busy } 75{ time } 76</ a > 77) as HTMLAnchorElement ; 78const name = conversationName ( 79link , 80preview , 81( title ) => api ( "PATCH" , `/api/conversations/ ${ tid } ` , { title }), 82); 83const changeArchived = () => { 84actions . open = false ; 85void api ( "PATCH" , `/api/conversations/ ${ tid } ` , { archived : ! shown ! . archived }). catch ( 86console . error , 87); 88}; 89const archive = ( 90< button 91type = "button" 92class = "archive" 93_onclick = { changeArchived } 94/ > 95) as HTMLButtonElement ; 96const mobileArchive = ( 97< button 98type = "button" 99class = "link" 100_onclick = { changeArchived } 101/ > 102) as HTMLButtonElement ; 103const actions = ( 104< details 105class = "conversation-actions" 106_onkeydown = {( event ) => { 107if ( event . key === "Escape" ) { 108actions . open = false ; 109actions . querySelector ( "summary" ) ! . focus (); 110} 111}} 112> 113< summary aria-label = "Conversation actions" title = "Conversation actions" > ⋯ </ summary > 114< div class = "conversation-menu" > 115< button 116type = "button" 117class = "link" 118_onclick = {() => { 119actions . open = false ; 120name . start (); 121}} 122> 123rename 124< /button> 125{ mobileArchive } 126< /div> 127</ details > 128) as HTMLDetailsElement ; 129const element = ( 130< div > 131{ chevron } 132{ name . editor } 133{ link } 134{ parentLink } 135{ archive } 136{ actions } 137</ div > 138) as HTMLElement ; 139const update = ( next : Row ) => { 140const previous = shown ; 141shown = next ; 142parentLink . hidden = ! next . parentLink ; 143if ( next . parentLink ) parentLink . href = `#/ ${ next . parentLink } ` ; 144else parentLink . removeAttribute ( "href" ); 145element . className = `item ${ next . active ? " active" : "" } ${ 146next . summary ?. unread ? " unread" : "" 147} ` ; 148if ( next . depth !== previous ?. depth ) element . style . setProperty ( "--depth" , String ( next . depth )); 149if ( 150next . forks !== previous ?. forks || next . open !== previous . open || 151next . agents !== previous ?. agents 152) { 153chevron . disabled = next . forks === 0 ; 154chevron . setAttribute ( "aria-expanded" , String ( next . open )); 155chevron . title = ` ${ next . forks } ${ next . forks === 1 ? "fork" : "forks" } ` + 156( next . agents > 0 157? `, ${ next . agents } live ${ next . agents === 1 ? "subagent" : "subagents" } ` 158: "" ); 159forks . textContent = next . open || next . forks === 0 ? "" : `+ ${ next . forks } ` ; 160} 161if ( next . summary !== previous ?. summary || next . title !== previous ?. title || previous === null ) { 162preview . className = next . title || next . summary ?. preview ? "preview" : "preview tid" ; 163name . update ( next . title , next . summary ?. preview || tid ); 164} 165const date = new Date ( next . summary ?. updated ?? tidDate ( tid ). getTime ()); 166time . textContent = relativeTime ( date ); 167time . title = date . toLocaleString (); 168time . dateTime = date . toISOString (); 169if ( next . summary !== previous ?. summary ) { 170busy . className = next . summary ?. busy 171? "busy on" 172: next . summary ?. unread 173? "busy unread" 174: "busy" ; 175busy . title = next . summary ?. busy ? "answering" : next . summary ?. unread ? "unread" : "" ; 176} 177if ( next . archived !== previous ?. archived ) { 178archive . textContent = mobileArchive . textContent = next . archived ? "restore" : "archive" ; 179} 180}; 181update ( first ); 182return { node : element , update , dispose : name . dispose }; 183} 184 185export function app ( scope : Scope ) : Element { 186const shared : Shared = { 187extensions : new Signal < readonly ExtensionInfo []>( EMPTY ), 188models : new Signal < readonly ModelInfo []>( EMPTY ), 189enabled : new Signal < readonly ModelRef []>( EMPTY ), 190machines : new Signal < readonly string []>([ "local" ]), 191projects : new Signal < ProjectIndex >({}), 192refreshModels : () = > { 193api < ModelInfo []>( "GET" , "/api/models" ). then (( list ) => shared . models . set ( list )). catch ( 194console . error , 195); 196}, 197}; 198scope . add ( 199watch <{ models : ModelRef []; environments ?: Record < string , unknown > }>( "settings" , ( value ) => { 200shared . enabled . set ( value . models ); 201shared . machines . set ([ "local" , ... Object . keys ( value . environments ?? {})]); 202}), 203); 204// views wait for the extensions' frontends, and draw again when one changes: a card keeps the renderer it was 205// drawn with 206const frontends = new Signal <{ version : number } | null >( null ); 207let syncing = Promise . resolve (); 208scope . add ( watch < ExtensionInfo []>( "extensions" , ( list ) => { 209syncing = syncing . then ( async () => { 210const changed = await syncRenderers ( list ); 211shared . extensions . set ( list ); 212if ( changed || frontends . get () === null ) { 213frontends . set ({ version : ( frontends . get ()?. version ?? 0 ) + 1 }); 214} 215}); 216})); 217shared . refreshModels (); 218 219// a conversation, or one of its extensions' views (`<tid>/<extension>`, then the view's own route); a draft 220// continuing it from one of its entries (`<tid>@<entry>`), or starting a new one from it (`<tid>/fork@<entry>`); 221// a new conversation (`new`, or `new/<project>` in a project, `new/` in none); or settings 222const route = () = > 223location . hash . match ( 224/ ^#\/([a-z2-7]{13}(?:(?:\/fork)?@\d+|\/[^/@]+(?:\/.*)?)?|new(?:\/[^/]*)?|settings)$ / , 225)?.[ 1 ] ?? "" ; 226const VIEW = / ^([a-z2-7]{13})\/([^/@]+(?:\/.*)?)$ / ; 227const selected = new Signal ( route ()); 228// the conversation stays put as its views open, close and move; it reads `viewing` once it is built, so first 229const viewing = computed ( scope , [ selected ], () => selected . get (). match ( VIEW )?.[ 2 ] ?? "" ); 230const page = computed ( scope , [ selected ], () => selected . get (). replace ( VIEW , "$1" )); 231let navigation = 0 ; 232const onHash = () => { 233const next = route (); 234const current = ++ navigation ; 235const apply = ( allowed : boolean ) => { 236if ( current !== navigation ) return ; 237if ( allowed ) selected . set ( next ); 238else document . defaultView ! . history . replaceState ( null , "" , `#/ ${ selected . get ()} ` ); 239}; 240const allowed = beforeNavigate ( next ); 241if ( typeof allowed === "boolean" ) apply ( allowed ); 242else void allowed . then ( apply , () => apply ( false )); 243}; 244addEventListener ( "hashchange" , onHash ); 245scope . add (() => { 246navigation ++ ; 247removeEventListener ( "hashchange" , onHash ); 248}); 249const selectedTid = () = > selected . get (). split ( / [@/] / )[ 0 ]; 250 251/** The conversations the sidebar lists: every conversation, owned or not. */ 252let listed : Record < string , ConversationInfo > = {}; 253let summaries : Record < string , Summary > = {}; 254const projectOrder = new Signal < readonly string []>([]); 255const editor = projectEditor ( scope , shared . machines , shared . projects , projectOrder ); 256// each list remembers its own folds, and the archive's projects start folded 257const folds = ( key : string , folded : boolean ) => { 258let flipped : Set < string >; 259try { 260flipped = new Set ( JSON . parse ( localStorage . getItem ( key ) ?? "[]" )); 261} catch { 262flipped = new Set (); 263} 264return { 265open : ( id : string ) => flipped . has ( id ) === folded , 266reveal : ( id : string ) => folded ? flipped . add ( id ) : flipped . delete ( id ), 267toggle : ( id : string ) => { 268if ( ! flipped . delete ( id )) flipped . add ( id ); 269localStorage . setItem ( key , JSON . stringify ([ ... flipped ])); 270}, 271}; 272}; 273const listFolds = folds ( "berry.collapsed-projects" , false ); 274const archiveFolds = folds ( "berry.expanded-archived-projects" , true ); 275const groupFor = ( tid : string ) => { 276const summary = summaries [ tid ]; 277return projectFor ( 278shared . projects . get (), 279summary ?. environment && summary . cwd 280? { environment : summary . environment , cwd : summary . cwd } 281: undefined , 282) ?? "" ; 283}; 284// a background subagent sits under the conversation that owns it, as a fork under its parent 285const above = ( tid : string ) => listed [ tid ]?. parent ?. tid ?? listed [ tid ]?. owner ; 286const archived = ( tid : string ) => listed [ tid ]. archived !== undefined ; 287// in the archive, an archived conversation is a root unless one it was forked from is archived too 288const archivedAbove = ( tid : string ) => { 289for ( 290let up = listed [ tid ]. parent ?. tid ; 291up && listed [ up ] && groupFor ( up ) === groupFor ( tid ); 292up = listed [ up ]. parent ?. tid 293) { 294if ( archived ( up )) return true ; 295} 296return false ; 297}; 298let revealed = "" ; 299// the list holds the conversations in use, or the archive: archiving one puts it away with the forks under it 300let archive = false ; 301// forks nest under their parents, newest first; the way to the selected conversation opens itself 302const opened = new Set < string >(); 303const reveal = () => { 304const group = groupFor ( selectedTid ()); 305if ( listed [ selectedTid ()]) { 306const inArchive = archived ( selectedTid ()) || archivedAbove ( selectedTid ()); 307( inArchive ? archiveFolds : listFolds ). reveal ( group ); 308} 309for ( let tid = above ( selectedTid ()); tid && groupFor ( tid ) === group ; tid = above ( tid )) { 310opened . add ( tid ); 311} 312revealed = ` ${ selectedTid ()} : ${ group } ` ; 313}; 314 315const list = < div class = "conversations" / >; 316// the group whose heading is being dragged to a new place in the order 317let dragging : string | undefined ; 318// a row's menu closes once the reader is elsewhere, another row's menu included 319const leaveMenus = ( event : Event ) => { 320for ( const menu of list . querySelectorAll < HTMLDetailsElement >( ".conversation-actions[open]" )) { 321if ( ! menu . contains ( event . target as Node )) menu . open = false ; 322} 323}; 324for ( const type of [ "pointerdown" , "focusin" ]) document . addEventListener ( type , leaveMenus ); 325scope . add (() => { 326for ( const type of [ "pointerdown" , "focusin" ]) document . removeEventListener ( type , leaveMenus ); 327}); 328// where a narrow screen, which shows the list or a view but not both, goes back to the list 329const back = < a href = "#/" class = "back" title = "conversations" aria - label = "conversations" / >; 330type Group = { 331kind : "group" ; 332id : string ; 333name : string ; 334open : boolean ; 335count : number ; 336unread : boolean ; 337busy : boolean ; 338}; 339type SidebarItem = Group | ( Row & { kind : "conversation" }); 340const rows = keyed < SidebarItem , string >( 341list , 342( item ) => item . kind === "group" ? `group: ${ item . id } ` : item . tid , 343( item ) => { 344if ( item . kind === "conversation" ) { 345const handle = row ( item , toggle ); 346return { 347node : handle . node , 348dispose : handle . dispose , 349update ( value : SidebarItem ) { 350if ( value . kind === "conversation" ) handle . update ?. ( value ); 351}, 352}; 353} 354const name = < span class = "project-name" / > as HTMLElement ; 355const count = < span class = "project-count" / > as HTMLElement ; 356const activity = < span /> as HTMLElement ; 357const toggleGroup = ( 358< button 359type = "button" 360class = "project-toggle" 361_onclick = {() => { 362( archive ? archiveFolds : listFolds ). toggle ( item . id ); 363draw (); 364}} 365> 366{ name } 367{ count } 368{ activity } 369< / button> 370) as HTMLButtonElement ; 371toggleGroup . draggable = true ; 372const add = ( 373< button 374type = "button" 375class = "link project-action" 376title = "new conversation" 377_onclick = {() => location . hash = `#/new/${ item . id } ` } 378> 379+ 380</ button > 381) as HTMLButtonElement ; 382const edit = ( 383< button 384type = "button" 385class = "link project-action" 386title = "edit project" 387_onclick = {() => editor . open ( item . id )} 388> 389⋯ 390< /button> 391) as HTMLButtonElement ; 392const node = ( 393< div class = "project-group" data - project = { item . id }> 394{ toggleGroup } 395{ add } 396{ edit } 397< / div> 398) as HTMLElement ; 399node . ondragstart = ( event ) => { 400dragging = item . id ; 401event . dataTransfer ?. setData ( "text/plain" , name . textContent ! ); 402}; 403node . ondragend = () => dragging = undefined ; 404node . ondragover = ( event ) => { 405if ( dragging === undefined || dragging === item . id ) return ; 406event . preventDefault (); 407// the dragged group takes this one's place, so lands after it when moving down 408const ids = sidebarOrder ( shared . projects . get (), projectOrder . get ()); 409node . dataset . drop = ids . indexOf ( dragging ) < ids . indexOf ( item . id ) ? "after" : "before" ; 410}; 411node . ondragleave = () => delete node . dataset . drop ; 412node . ondrop = ( event ) => { 413event . preventDefault (); 414delete node . dataset . drop ; 415const projects = shared . projects . get (); 416const at = sidebarOrder ( projects , projectOrder . get ()). indexOf ( item . id ); 417moveGroup ( projects , projectOrder , dragging ! , at ). catch (( error ) => alert ( error . message )); 418}; 419const update = ( value : SidebarItem ) = > { 420if ( value . kind !== "group" ) return ; 421name . textContent = value . name ; 422count . textContent = value . count ? String ( value . count ) : "" ; 423toggleGroup . setAttribute ( "aria-expanded" , String ( value . open )); 424// the rows say how each is doing while they show 425activity . className = value . open 426? "busy" 427: value . busy 428? "busy on" 429: value . unread 430? "busy unread" 431: "busy" ; 432activity . title = value . open ? "" : value . busy ? "answering" : value . unread ? "unread" : "" ; 433add . setAttribute ( "aria-label" , `New conversation in ${ value . name } ` ); 434edit . setAttribute ( "aria-label" , `Edit ${ value . name } ` ); 435}; 436update ( item ); 437return { node , update }; 438}, 439); 440const archiveToggle = ( 441< button 442type = "button" 443class = "link archive-toggle" 444_onclick = {() => { 445archive = ! archive ; 446draw (); 447}} 448/ > 449) as HTMLButtonElement ; 450function draw () { 451if ( revealed !== ` ${ selectedTid ()} : ${ groupFor ( selectedTid ())} ` ) reveal (); 452const owned = ( tid : string ) => listed [ tid ]. owner !== undefined ; 453// a subagent shows only while it works, and never in the archive or a group's count 454const ordered = Object . keys ( listed ) 455. filter (( tid ) => ! owned ( tid ) || ( ! archive && summaries [ tid ]?. busy && ! archived ( tid ))) 456. sort (( a , b ) => 457( summaries [ b ]?. updated ?? listed [ b ]. created ) - 458( summaries [ a ]?. updated ?? listed [ a ]. created ) || b . localeCompare ( a ) 459); 460const children = Map . groupBy ( ordered , ( tid ) => { 461const up = above ( tid ); 462return up && listed [ up ] && groupFor ( up ) === groupFor ( tid ) ? up : null ; 463}); 464const active = selectedTid (); 465const items : SidebarItem [ ] = []; 466const visit = ( tid: string , depth: number ) => { 467const below = children . get ( tid ) ?? []; 468const forks = below . filter (( fork ) => ! owned ( fork ) && ( archive || ! archived ( fork ))); 469const agents = below . filter ( owned ); 470const open = opened . has ( tid ); 471const up = above ( tid ); 472items . push ({ 473kind : "conversation" , 474tid , 475parentLink : up && listed [ up ] && groupFor ( up ) !== groupFor ( tid ) ? up : undefined , 476title : listed [ tid ]. title , 477depth , 478forks : forks . length , 479agents : agents . length , 480subagent : owned ( tid ), 481open , 482archived : archived ( tid ), 483summary : summaries [ tid ] ?? null , 484active : tid === active , 485}); 486if ( open ) { for ( const fork of forks ) visit ( fork , depth + 1 ); } 487// a subagent shows whether or not the forks beside it are open 488for ( const agent of agents ) visit ( agent , depth + 1 ); 489}; 490const roots = archive 491? ordered . filter (( tid ) => archived ( tid ) && ! archivedAbove ( tid )) 492: ( children . get ( null ) ?? []). filter (( tid ) => ! archived ( tid )); 493const projects = shared . projects . get (); 494// without projects, the list is as it always was: no headings, nothing to fold 495const headed = Object . keys ( projects ). length > 0 ; 496for ( const id of sidebarOrder ( projects , projectOrder . get ())) { 497const group = { id , name : id === "" ? "no project" : projects [ id ]. name }; 498const members = ordered . filter (( tid ) => 499! owned ( tid ) && groupFor ( tid ) === group . id && 500( archive === ( archived ( tid ) || archivedAbove ( tid ))) 501); 502// an empty project stays, to be started in; the archive shows only what it holds 503if ( members . length === 0 && ( archive || group . id === "" )) continue ; 504const open = ! headed || ( archive ? archiveFolds : listFolds ). open ( group . id ); 505if ( headed ) { 506items . push ({ 507kind : "group" , 508...group , 509open , 510count : members . length , 511busy : members . some (( tid ) => summaries [ tid ]?. busy ), 512unread : members . some (( tid ) => summaries [ tid ]?. unread ), 513}); 514} 515if ( open ) { 516for ( const tid of roots . filter (( tid ) => groupFor ( tid ) === group . id )) visit ( tid , 0 ); 517} 518} 519rows . sync ( items ); 520list . toggleAttribute ( "data-archive" , archive ); 521back . classList . toggle ( 522"unread" , 523Object . keys ( listed ). some (( tid ) => tid !== active && summaries [ tid ]?. unread ), 524); 525 526const archivedCount = 527Object . entries ( listed ). filter (([ tid , info ]) => info . archived !== undefined && ! owned ( tid )) 528. length ; 529archiveToggle . hidden = ! archive && archivedCount === 0 ; 530archiveToggle . textContent = archive ? "back to conversations" : `archived ( ${ archivedCount } )` ; 531} 532function toggle ( tid: string ) { 533if ( ! opened . delete ( tid )) opened . add ( tid ); 534draw (); 535} 536scope . add ( watch < Record < string , ConversationInfo >>( "conversations" , ( index ) => { 537listed = { ...index }; 538reveal (); 539draw (); 540})); 541const announcing = announcements ({ 542active : selectedTid , 543name : ( tid ) => listed [ tid ]?. title ?? summaries [ tid ]?. preview ?? "" , 544}); 545scope . add ( watch < Record < string , Summary >>( "summaries" , ( value ) => { 546summaries = value ; 547announcing ( value ); 548draw (); 549})); 550scope . add ( 551selected . subscribe (() => { 552reveal (); 553draw (); 554}). unsubscribe , 555); 556scope . add ( rows . dispose ); 557scope . add ( watch < ProjectIndex >( "projects" , ( value ) => shared . projects . set ( value ))); 558scope . add ( shared . projects . subscribe ( draw ). unsubscribe ); 559scope . add ( watch <{ ids : string [] }>( "project-order" , ( value ) => projectOrder . set ( value . ids ))); 560scope . add ( projectOrder . subscribe ( draw ). unsubscribe ); 561const clock = setInterval ( draw , 60_000 ); 562scope . add (() => clearInterval ( clock )); 563draw (); 564 565const main = < div class = "view" / >; 566// each view brings its bar and its pane, which take their own cells of the app's grid 567swap ( 568scope , 569main , 570computed ( 571scope , 572[ page , frontends ], 573() => ( frontends . get () ? { route : page . get () } : null ), 574), 575( view , viewScope ) => 576view === null ? [< header class = "bar" / >, <div class="pane empty" / >] : view . route === "" 577? [ 578< header class = "bar" / >, 579< div class = "pane empty" > 580< p > 581< a href = "#/new" > start a conversation </ a > , or pick one 582</ p > 583< / div>, 584] 585: view . route === "settings" 586? settings ( viewScope , shared ) 587: view . route === "new" || view . route . startsWith ( "new/" ) 588? newConversation ( viewScope , shared , view . route === "new" ? null : view . route . slice ( 4 )) 589: view . route . includes ( "@" ) 590? draft ( 591viewScope , 592view . route . split ( / [@/] / )[ 0 ], 593Number ( view . route . split ( "@" )[ 1 ]), 594view . route . includes ( "/fork" ), 595) 596: conversation ( viewScope , view . route , shared , viewing ), 597); 598 599const dot = < span class = "dot" title = "connection" / > as HTMLElement ; 600scope . add ( 601connected . subscribeImmediate (( up ) => ( dot . className = up ? "dot up" : "dot" )). unsubscribe , 602); 603 604return ( 605< div 606class = { selected . derive (( route ) => 607route === "" ? "app listing" : VIEW . test ( route ) ? "app viewing" : "app" 608)} 609> 610< header class = "brand-bar" > 611{ dot } 612< span class = "brand" > berry < /span> 613< a 614href = "#/settings" 615class = { selected . derive (( current ) => ( current === "settings" ? "active" : "" ))} 616> 617settings 618</ a > 619< button 620type = "button" 621class = "link" 622title = "restart berry, interrupting then auto-restarting conversation turns" 623_onclick = { async function () { 624this . disabled = true ; 625try { 626await api ( "POST" , "/api/restart" ); 627} catch ( error ) { 628alert ( error instanceof Error ? error . message : String ( error )); 629} finally { 630this . disabled = false ; 631} 632}} 633> 634restart 635< / button> 636</ header > 637< nav class = "sidebar" > 638< a 639href = "#/new" 640class = { selected . derive (( current ) => 641current === "new" || current . startsWith ( "new/" ) ? "new active" : "new" 642)} 643> 644new conversation 645</ a > 646{ list } 647< footer class = "sidebar-footer" > 648< button type = "button" class = "link" _onclick = {() => editor . open ()}> 649new project 650</ button > 651{archiveToggle} 652< / footer> 653</ nav > 654{back} 655{main} 656{ editor . element } 657</ div > 658); 659}