2026-10-15 10:36:32 -07:00
import { get , post , type ByreEvent , type Project , type Schemas , type Task } from "./api.js" ;
2026-10-15 07:23:17 -07:00
import { errorText , h , replace , showAlert } from "./dom.js" ;
import { markdown } from "./markdown.js" ;
import { models } from "./task.js" ;
2026-10-16 10:36:38 -07:00
import { currentSession , findTask , focusedProject , focusTask , on , onEach , state , taskState } from "./state.js" ;
2026-10-15 13:30:09 -07:00
import { taskStyle } from "./taskcolor.js" ;
2026-10-15 07:23:17 -07:00
type Todo = Schemas [ "Todo" ];
type TodoItem = Schemas [ "TodoItem" ];
const refreshKinds = new Set ([ "task.merged" , "project.created" ]);
2026-10-15 10:36:32 -07:00
export function mountProject ( root : HTMLElement ) : () => void {
2026-10-15 07:23:17 -07:00
const todos = new Map < string , Todo | string >();
const starting = new Set < string >();
let disposed = false ;
const render = () => {
2026-10-15 10:36:32 -07:00
const scrollTop = root . querySelector ( ".project-view" ) ? . scrollTop ?? 0 ;
2026-10-15 09:59:13 -07:00
const p = focusedProject ();
2026-10-15 10:36:32 -07:00
replace ( root , h ( "div" , { class : "project-view" }, p ? projectBlock ( p , todos . get ( p . id ), starting , start ) : h ( "div" , { class : "empty" }, "No project selected." )));
root . querySelector ( ".project-view" ) ! . scrollTop = scrollTop ;
2026-10-15 07:23:17 -07:00
};
const load = async ( projectId : string ) => {
try {
const todo = await get ( "/api/projects/{projectId}/todo" , { path : { projectId } });
todos . set ( projectId , todo );
} catch ( err ) {
todos . set ( projectId , `Couldn't read TODO.md: ${ errorText ( err ) } ` );
}
if ( ! disposed ) {
render ();
}
};
const refresh = () => {
2026-10-15 09:59:13 -07:00
const p = focusedProject ();
2026-10-15 09:40:30 -07:00
if ( p ) {
2026-10-15 07:23:17 -07:00
void load ( p . id );
}
};
2026-10-16 08:53:02 -07:00
const start = async ( project : Project , item : TodoItem , discuss : boolean ) => {
2026-10-15 07:23:17 -07:00
const key = ` ${ project . id } \ n ${ item . title } ` ;
starting . add ( key );
render ();
try {
2026-10-16 08:53:02 -07:00
await post ( "/api/projects/{projectId}/todo/tasks" , { path : { projectId : project.id }, body : { title : item.title , model : models [ 0 ], discuss } });
2026-10-15 07:23:17 -07:00
} catch ( err ) {
void showAlert ( errorText ( err ));
}
starting . delete ( key );
if ( ! disposed ) {
render ();
}
};
const offs = [
on ( "event" , ( detail? : unknown ) => {
const ev = detail as ByreEvent ;
if ( ! refreshKinds . has ( ev . kind )) {
return ;
}
if ( ev . project_id ) {
void load ( ev . project_id );
}
}),
on ( "connection" , () => {
if ( state . connected ) {
refresh ();
}
}),
on ( "projects" , refresh ),
2026-10-15 09:59:13 -07:00
onEach ([ "focus" , "selection" ], () => {
2026-10-15 09:40:30 -07:00
render ();
2026-10-15 09:59:13 -07:00
const p = focusedProject ();
2026-10-15 09:40:30 -07:00
if ( p && ! todos . has ( p . id )) {
void load ( p . id );
}
}),
2026-10-15 10:36:32 -07:00
onEach ([ "tasks" , "sessions" , "inbox" ], render ),
2026-10-15 07:23:17 -07:00
];
render ();
refresh ();
return () => {
disposed = true ;
for ( const off of offs ) {
off ();
}
};
}
2026-10-16 08:53:02 -07:00
function projectBlock ( project : Project , todo : Todo | string | undefined , starting : Set < string >, start : ( p : Project , item : TodoItem , discuss : boolean ) => Promise < void >) : HTMLElement {
2026-10-15 10:36:32 -07:00
const active = ( state . tasks . get ( project . id ) ?? []). filter (( t ) => t . status === "active" );
return h (
"section" ,
{ class : "project" },
h ( "h2" , {}, project . name , " " , h ( "span" , { class : "repo" }, project . default_branch )),
h ( "h3" , {}, "Tasks" ),
tasksBlock ( active ),
h ( "h3" , {}, "TODO" ),
... todoBlock ( project , todo , active , starting , start ),
);
}
2026-10-16 10:36:38 -07:00
function nested ( active : Task []) : { task : Task ; depth : number }[] {
const ids = new Set ( active . map (( t ) => t . id ));
const out : { task : Task ; depth : number }[] = [];
const add = ( task : Task , depth : number ) => {
out . push ({ task , depth });
for ( const child of active . filter (( t ) => t . parent_task_id === task . id ). reverse ()) {
add ( child , depth + 1 );
}
};
for ( const task of active . filter (( t ) => t . parent_task_id === undefined || ! ids . has ( t . parent_task_id ))) {
add ( task , 0 );
}
return out ;
}
2026-10-15 10:36:32 -07:00
function tasksBlock ( active : Task []) : HTMLElement {
if ( active . length === 0 ) {
return h ( "p" , { class : "empty-row" }, "No active tasks." );
}
return h (
"ul" ,
{ class : "task-list" },
2026-10-16 10:36:38 -07:00
... nested ( active ). map (({ task , depth }) => {
2026-10-16 08:37:24 -07:00
const ts = taskState ( task );
2026-10-15 10:36:32 -07:00
const count = state . inbox . filter (( item ) => item . task_id === task . id ). length ;
2026-10-16 10:36:38 -07:00
const classes = [ task . id === state . selectedTaskId && "selected" , task . parent_task_id !== undefined && "subtask" ]. filter ( Boolean ). join ( " " );
2026-10-15 10:36:32 -07:00
return h (
"li" ,
2026-10-16 10:36:38 -07:00
{ class : classes , style : ` ${ taskStyle ( task . id ) } ; --depth: ${ depth } ` },
2026-10-15 10:36:32 -07:00
h (
"a" ,
{
href : `#/task/ ${ task . id } ` ,
onclick : ( e : MouseEvent ) => {
e . preventDefault ();
focusTask ( task . id );
},
},
2026-10-16 10:36:38 -07:00
task . parent_task_id !== undefined && h ( "span" , { class : "subtask-mark" , title : `Subtask of ${ findTask ( task . parent_task_id ) ? . title ?? task . parent_task_id } ` }, "↳" ),
2026-10-16 08:37:24 -07:00
h ( "span" , { class : `dot ${ ts ?? "none" } ` , title : ts ?? "no session" }),
2026-10-15 13:30:09 -07:00
h ( "span" , { class : "task-title task-name" }, task . title ),
2026-10-15 10:36:32 -07:00
count > 0 && h ( "span" , { class : "badge" , title : "inbox items" }, count ),
2026-10-15 12:40:41 -07:00
task . merge_queued_at !== undefined && h ( "span" , { class : "task-state warn" , title : "Waiting for the merge in progress in this project to finish" }, "merge held" ),
2026-10-16 10:36:38 -07:00
task . merging_into_parent && h ( "span" , { class : "task-state warn" , title : "Waiting for the parent task's agent to merge it" }, "merging into parent" ),
2026-10-16 08:37:24 -07:00
h ( "span" , { class : "task-state" }, ts ?? "no agent" ),
2026-10-15 10:36:32 -07:00
),
);
}),
);
}
2026-10-15 07:23:17 -07:00
2026-10-16 08:53:02 -07:00
function todoBlock ( project : Project , todo : Todo | string | undefined , active : Task [], starting : Set < string >, start : ( p : Project , item : TodoItem , discuss : boolean ) => Promise < void >) : HTMLElement [] {
2026-10-15 07:23:17 -07:00
if ( todo === undefined ) {
2026-10-15 10:36:32 -07:00
return [ h ( "p" , { class : "empty-row" }, "Loading…" )];
2026-10-15 07:23:17 -07:00
}
if ( typeof todo === "string" ) {
2026-10-15 10:36:32 -07:00
return [ h ( "p" , { class : "empty-row error-text" }, todo )];
2026-10-15 07:23:17 -07:00
}
if ( ! todo . exists ) {
2026-10-15 10:36:32 -07:00
return [ h ( "p" , { class : "empty-row" }, `No TODO.md on ${ project . default_branch } .` )];
2026-10-15 07:23:17 -07:00
}
if ( todo . items . length === 0 ) {
2026-10-15 10:36:32 -07:00
return [ h ( "p" , { class : "empty-row" }, "TODO.md has no items." )];
2026-10-15 07:23:17 -07:00
}
const children : HTMLElement [] = [];
let section = "" ;
let list : HTMLElement | undefined ;
for ( const item of todo . items ) {
if ( ! list || item . section !== section ) {
section = item . section ;
if ( section ) {
2026-10-15 10:36:32 -07:00
children . push ( h ( "h4" , { class : "todo-section" }, section ));
2026-10-15 07:23:17 -07:00
}
list = h ( "ul" , { class : "todo-list" });
children . push ( list );
}
const task = active . find (( t ) => t . todo_item === item . title );
let action : HTMLElement ;
if ( task ) {
const sess = currentSession ( task . id );
action = h (
"a" ,
2026-10-15 09:40:30 -07:00
{
class : "todo-task" ,
href : `#/task/ ${ task . id } ` ,
onclick : ( e : MouseEvent ) => {
e . preventDefault ();
focusTask ( task . id );
},
},
2026-10-15 07:23:17 -07:00
h ( "span" , { class : `dot ${ sess ? . state ?? "none" } ` , title : sess?.state ?? "no session" }),
sess ? . state ?? "no agent" ,
);
} else {
const busy = starting . has ( ` ${ project . id } \ n ${ item . title } ` );
2026-10-16 08:53:02 -07:00
const startButton = h ( "button" , { disabled : busy , title : "Start a task that gets straight to work" }, busy ? "Starting…" : "Start" );
startButton . onclick = () => void start ( project , item , false );
2026-10-16 09:32:07 -07:00
const discussButton = h ( "button" , { disabled : busy , title : "Start a task that discusses it with you in chat before building anything" }, "Discuss" );
2026-10-16 08:53:02 -07:00
discussButton . onclick = () => void start ( project , item , true );
action = h ( "span" , { class : "todo-buttons" }, startButton , discussButton );
2026-10-15 07:23:17 -07:00
}
list . append (
h (
"li" ,
2026-10-15 13:30:09 -07:00
{ class : task && task . id === state . selectedTaskId ? "selected" : "" , style : task ? taskStyle ( task . id ) : "" },
h ( "div" , { class : "todo-head" }, h ( "span" , { class : task ? "todo-title task-name" : "todo-title" }, item . title ), action ),
2026-10-15 07:23:17 -07:00
item . body && markdown ( item . body , "md todo-body" ),
),
);
}
2026-10-15 10:36:32 -07:00
return children ;
2026-10-15 07:23:17 -07:00
}