2026-10-14 23:38:17 -07:00
import { get , post } from "./api.js" ;
2026-10-14 22:42:46 -07:00
import { ago , errorText , h , replace , showAlert , showConfirm } from "./dom.js" ;
2026-10-14 19:35:34 -07:00
import { markdown } from "./markdown.js" ;
2026-10-15 09:40:30 -07:00
import { currentSession , findTask , focusTab , onEach , pinSnapshot , setFocus , state } from "./state.js" ;
2026-10-15 07:23:17 -07:00
export const models = [ "claude-opus-5-5" , "claude-sonnet-5-5" , "claude-haiku-5-5" , "claude-fable-5-1" ];
2026-10-15 09:59:13 -07:00
const quietReply = "NO_REPLY" ;
2026-10-15 09:40:30 -07:00
const taskSource = {
key : () => state . selectedTaskId ,
events : () => state . transcript ,
session : () => ( state . selectedTaskId ? currentSession ( state . selectedTaskId ) : undefined ),
root : () => ( state . selectedTaskId ? ( findTask ( state . selectedTaskId ) ? . worktree_path ?? "" ) : "" ),
taskId : () => state . selectedTaskId ,
resetTopics : [ "selection" ],
refreshTopics : [ "tasks" , "sessions" ],
transcriptTopic : "transcript" ,
streamTopic : "stream" ,
empty : "No task selected. Ask your lead, or pick one with ⌘K." ,
canSend : ( s ) => s === "idle" ,
placeholder : ( s ) => ( s === "idle" ? "Message the agent. Paste or drop images to attach them." : "Wait for the turn to end, or interrupt it" ),
send : async ({ key , sessionId , text , images , model , firstModel }) => {
if ( sessionId ) {
await post ( "/api/sessions/{sessionId}/messages" , { path : { sessionId }, body : { text , images , model } });
return ;
}
await post ( "/api/tasks/{taskId}/sessions" , { path : { taskId : key }, body : { prompt : text , model : firstModel , images } });
},
};
2026-10-14 09:40:12 -07:00
export function mountTranscript ( root ) {
2026-10-15 09:40:30 -07:00
return mountChat ( root , taskSource , taskHead );
}
function taskHead ( rerender ) {
let unmerged ;
let timer ;
const load = async () => {
const taskId = state . selectedTaskId ;
if ( ! taskId ) {
return ;
}
try {
const u = await get ( "/api/tasks/{taskId}/unmerged" , { path : { taskId } });
if ( state . selectedTaskId !== taskId ) {
return ;
}
const has = u . commits > 0 || u . files > 0 ;
if ( unmerged ? . taskId === taskId && unmerged . has === has ) {
return ;
}
unmerged = { taskId , has };
rerender ();
}
catch ( err ) {
console . error ( "load unmerged changes" , err );
}
};
return {
reset : () => {
clearTimeout ( timer );
void load ();
},
dispose : () => clearTimeout ( timer ),
onEvents : ( events ) => {
if ( events . some (( ev ) => ev . kind === "agent.tool_result" || ev . kind === "turn.ended" )) {
clearTimeout ( timer );
timer = setTimeout (() => void load (), 1500 );
}
},
render : ( usage ) => {
const task = state . selectedTaskId ? findTask ( state . selectedTaskId ) : undefined ;
if ( ! task ) {
return [];
}
const sess = currentSession ( task . id );
if ( task . status !== "active" ) {
return [ h ( "div" , { class : "head-actions" }, task . status )];
}
const target = state . projects . find (( p ) => p . id === task . project_id ) ? . default_branch ?? "the default branch" ;
const busy = sess ? . state === "running" || sess ? . state === "queued" ;
const merge = h ( "button" , { disabled : busy || unmerged ? . taskId !== task . id || ! unmerged . has , title : `The agent merges ${ target } in, then byre squashes into one commit on ${ target } and pushes it to origin` }, "Merge" );
merge . onclick = async () => {
let coAuthors ;
try {
coAuthors = await get ( "/api/tasks/{taskId}/co-authors" , { path : { taskId : task . id } });
}
catch ( err ) {
void showAlert ( errorText ( err ));
return ;
}
await confirmThen ( mergeQuestion ( task . merge_title , target , coAuthors ), "Merge" , false , async () => {
await post ( "/api/tasks/{taskId}/merge" , { path : { taskId : task . id } });
});
};
const mergeIn = h ( "button" , { disabled : busy , title : `Ask the agent to bring ${ target } into this branch and resolve any conflicts` }, `Sync from ${ target } ` );
mergeIn . onclick = async () => {
mergeIn . disabled = true ;
try {
await post ( "/api/tasks/{taskId}/merge-in" , { path : { taskId : task . id } });
}
catch ( err ) {
void showAlert ( errorText ( err ));
mergeIn . disabled = false ;
}
};
const discard = h ( "button" , { class : "danger" , disabled : busy , title : "Delete the worktree and branch without merging" }, "Discard" );
discard . onclick = () => confirmThen ( `Discard " ${ task . title } "?` , "Discard" , true , async () => {
await post ( "/api/tasks/{taskId}/discard" , { path : { taskId : task . id } });
await setFocus ({ task_id : null });
});
return [
h ( "div" , { class : "head-actions session-actions" }, compactButton ( sess ? . id , sess ? . state , sess && usage ( sess . id )), mergeIn ),
h ( "div" , { class : "head-actions" }, merge , discard ),
];
},
};
}
export function mountChat ( root , src , makeHead ) {
2026-10-14 09:40:12 -07:00
const head = h ( "header" , { class : "task-header" });
const transcript = h ( "div" , { class : "transcript" });
const composer = h ( "div" , { class : "composer" });
2026-10-14 16:03:46 -07:00
const visibleReports = new Map ();
const acked = new Set ();
const ackVisible = () => {
if ( document . visibilityState !== "visible" ) {
return ;
}
const ids = [... new Set ( visibleReports . values ())]. filter (( id ) => ! acked . has ( id ) && state . inbox . some (( i ) => i . id === id ));
if ( ids . length === 0 ) {
return ;
}
for ( const id of ids ) {
acked . add ( id );
}
post ( "/api/inbox/responses" , { body : { responses : ids . map (( id ) => ({ item_id : id , type : "dismiss" })) } }). catch (( err ) => {
for ( const id of ids ) {
acked . delete ( id );
}
console . error ( "acknowledge reports" , err );
});
};
const observer = new IntersectionObserver (( entries ) => {
for ( const e of entries ) {
const id = e . target . dataset [ "inboxItem" ];
if ( ! id ) {
continue ;
}
if ( e . isIntersecting ) {
visibleReports . set ( e . target , id );
}
else {
visibleReports . delete ( e . target );
}
}
ackVisible ();
}, { root : transcript , threshold : 0.5 });
2026-10-15 09:40:30 -07:00
const newCtx = () => ({
tools : new Map (),
run : undefined ,
anchors : [],
unplaced : [],
lastMessage : undefined ,
turnStart : undefined ,
2026-10-15 09:59:13 -07:00
quiet : false ,
2026-10-15 10:30:24 -07:00
handoff : undefined ,
2026-10-15 09:40:30 -07:00
root : src . root (),
taskId : src . taskId (),
watchReport : ( el , itemId ) => {
el . dataset [ "inboxItem" ] = itemId ;
observer . observe ( el );
},
});
2026-10-14 16:03:46 -07:00
let ctx = newCtx ();
let context = new Map ();
2026-10-15 09:40:30 -07:00
const headImpl = makeHead (() => renderHead ());
2026-10-14 23:22:44 -07:00
const spinner = h ( "div" , { class : "spinner" , hidden : true });
2026-10-14 16:03:46 -07:00
const setRunning = ( running ) => {
transcript . classList . toggle ( "running" , running );
if ( running === ! spinner . hidden ) {
return ;
}
const pinned = transcript . scrollHeight - transcript . scrollTop - transcript . clientHeight < 80 ;
spinner . hidden = ! running ;
if ( pinned ) {
transcript . scrollTop = transcript . scrollHeight ;
}
};
2026-10-14 23:53:17 -07:00
const putBefore = ( el , anchor ) => {
if ( anchor ? . parentNode === transcript ) {
transcript . insertBefore ( el , anchor );
}
else if ( spinner . parentNode === transcript ) {
2026-10-14 16:03:46 -07:00
transcript . insertBefore ( el , spinner );
}
else {
transcript . append ( el );
}
};
2026-10-15 10:30:24 -07:00
const put = ( el ) => {
if ( ctx . handoff ) {
ctx . handoff . body . append ( el );
return ;
}
putBefore ( el , pending [ 0 ] ? . el );
};
2026-10-14 09:40:12 -07:00
const renderHead = () => {
2026-10-15 09:40:30 -07:00
setRunning ( src . session () ? . state === "running" );
if ( ! src . key ()) {
2026-10-14 09:40:12 -07:00
replace ( head );
return ;
}
2026-10-15 09:40:30 -07:00
replace ( head , ... headImpl . render (( id ) => context . get ( id )));
2026-10-14 09:40:12 -07:00
};
2026-10-14 19:35:34 -07:00
let composerModel = "" ;
2026-10-14 22:42:46 -07:00
let focusStart = false ;
2026-10-14 09:40:12 -07:00
const renderComposer = () => {
2026-10-15 09:40:30 -07:00
const key = src . key ();
if ( ! key ) {
2026-10-14 09:40:12 -07:00
replace ( composer );
return ;
}
2026-10-15 09:40:30 -07:00
const sess = src . session ();
2026-10-14 22:50:13 -07:00
const live = sess && sess . state !== "completed" ? sess : undefined ;
composerModel = live ? live . model || context . get ( live . id ) ? . model || "" : "" ;
2026-10-15 09:40:30 -07:00
const form = composeForm ( src , key , live ? . id , live ? . state ?? "idle" , composerModel , showPending );
2026-10-14 23:34:39 -07:00
const old = composer . querySelector ( "textarea" );
const caret = old !== null && document . activeElement === old ? [ old . selectionStart , old . selectionEnd ] : undefined ;
2026-10-14 22:50:13 -07:00
replace ( composer , form );
2026-10-14 23:34:39 -07:00
const text = form . querySelector ( "textarea" );
if ( caret ) {
text . focus ();
text . setSelectionRange ( caret [ 0 ], caret [ 1 ]);
}
else if ( focusStart && ! sess ) {
text . focus ();
2026-10-14 09:40:12 -07:00
}
2026-10-14 22:42:46 -07:00
focusStart = false ;
2026-10-14 22:50:13 -07:00
};
let pending = [];
const showPending = ( text , imgs ) => {
const el = h ( "div" , { class : "msg user pending" }, h ( "div" , { class : "who" }, "sending…" ), imgs . length > 0 ? h ( "div" , { class : "msg-images" }, ... imgs . map (( img ) => h ( "img" , { src : img . url , alt : "attached image" }))) : null , text . trim () ? markdown ( text , "text md" ) : null );
const entry = { el , text , imgs };
pending . push ( entry );
ctx . run = undefined ;
2026-10-14 23:53:17 -07:00
putBefore ( el , spinner );
2026-10-14 22:50:13 -07:00
transcript . scrollTop = transcript . scrollHeight ;
return () => {
el . remove ();
pending = pending . filter (( p ) => p !== entry );
};
};
const confirmPending = ( text ) => {
const entry = pending . find (( p ) => p . text === text );
if ( ! entry ) {
return ;
}
entry . el . remove ();
releaseImages ( entry . imgs );
pending = pending . filter (( p ) => p !== entry );
2026-10-14 16:03:46 -07:00
};
const addToRun = ( el , call ) => {
const run = ctx . run ;
if ( ! run ) {
2026-10-15 00:08:32 -07:00
ctx . run = { first : el , group : undefined , calls : call ? [ call ] : [], snaps : [] };
2026-10-14 16:03:46 -07:00
put ( el );
return ;
}
if ( call ) {
run . calls . push ( call );
}
if ( ! run . group ) {
const summary = h ( "summary" );
const body = h ( "div" , { class : "tool-group-body" });
const group = h ( "details" , { class : "tool-group" }, summary , body );
run . first . replaceWith ( group );
body . append ( run . first );
run . group = { el : group , summary , body };
}
run . group . body . append ( el );
2026-10-15 00:08:32 -07:00
replace ( run . group . summary , runSummary ( run . calls ), snapMark ( run . snaps ));
};
const addAnchor = ( seq , el , turnEnd ) => {
const anchor = { seq , turnEnd , el , snaps : [], mark : undefined , run : turnEnd ? undefined : ctx . run };
ctx . anchors . push ( anchor );
if ( ! turnEnd ) {
return ;
}
const waiting = ctx . unplaced ;
ctx . unplaced = [];
for ( const snap of waiting ) {
placeSnapshot ( snap );
}
};
const markAnchor = ( anchor , n ) => {
anchor . snaps . push ( n );
anchor . mark ? . remove ();
anchor . mark = snapMark ( anchor . snaps ) ?? undefined ;
if ( anchor . mark ) {
anchor . el . append ( anchor . mark );
}
const run = anchor . run ;
if ( ! run ) {
return ;
}
run . snaps . push ( n );
if ( run . group ) {
replace ( run . group . summary , runSummary ( run . calls ), snapMark ( run . snaps ));
}
};
const placeSnapshot = ( snap ) => {
const before = ctx . anchors . findLast (( a ) => a . seq <= snap . event_seq );
if ( snap . trigger !== "turn_end" || before ? . turnEnd ) {
if ( before ) {
markAnchor ( before , snap . n );
}
return ;
}
const after = ctx . anchors . find (( a ) => a . turnEnd && a . seq > snap . event_seq );
if ( after ) {
markAnchor ( after , snap . n );
return ;
}
ctx . unplaced . push ( snap );
2026-10-14 09:40:12 -07:00
};
2026-10-14 19:35:34 -07:00
let stream ;
const dropStream = () => {
if ( ! stream ) {
return ;
}
2026-10-14 22:28:25 -07:00
clearTimeout ( stream . timer );
2026-10-14 19:35:34 -07:00
stream . el . remove ();
stream = undefined ;
};
const appendDelta = ( ev ) => {
2026-10-15 10:30:24 -07:00
if ( ctx . handoff ) {
return ;
}
2026-10-14 19:35:34 -07:00
if ( ! stream ) {
2026-10-14 22:28:25 -07:00
stream = { el : h ( "div" , { class : "msg agent" }), text : "" , timer : 0 };
2026-10-14 19:35:34 -07:00
ctx . run = undefined ;
put ( stream . el );
}
stream . text += String ( ev . payload [ "text" ] ?? "" );
2026-10-14 22:28:25 -07:00
if ( stream . timer ) {
2026-10-14 19:35:34 -07:00
return ;
}
2026-10-14 22:28:25 -07:00
stream . timer = setTimeout (() => {
2026-10-14 19:35:34 -07:00
if ( ! stream ) {
return ;
}
2026-10-14 22:28:25 -07:00
stream . timer = 0 ;
2026-10-14 19:35:34 -07:00
const pinned = transcript . scrollHeight - transcript . scrollTop - transcript . clientHeight < 80 ;
2026-10-15 09:59:13 -07:00
stream . el . hidden = quietReply . startsWith ( stream . text . trim ());
2026-10-15 09:40:30 -07:00
replace ( stream . el , markdown ( stream . text , "text md" , { taskId : ctx . taskId }));
2026-10-14 19:35:34 -07:00
if ( pinned ) {
transcript . scrollTop = transcript . scrollHeight ;
}
2026-10-14 22:28:25 -07:00
}, 100 );
2026-10-14 19:35:34 -07:00
};
2026-10-15 10:30:24 -07:00
const openHandoff = () => {
const summary = h ( "summary" , {}, "writing a handoff…" );
2026-10-14 19:35:34 -07:00
const body = h ( "div" , { class : "folded-turn" });
2026-10-15 10:30:24 -07:00
put ( h ( "details" , { class : "note" }, summary , body ));
ctx . handoff = { summary , body };
};
const closeHandoff = ( end ) => {
const handoff = ctx . handoff ;
if ( ! handoff ) {
return undefined ;
}
ctx . handoff = undefined ;
if ( end ) {
replace ( handoff . summary , ... end . childNodes );
}
else {
replace ( handoff . summary , "handoff not written" );
}
return handoff . summary ;
2026-10-14 19:35:34 -07:00
};
2026-10-14 09:40:12 -07:00
const appendEvents = ( events ) => {
const pinned = transcript . scrollHeight - transcript . scrollTop - transcript . clientHeight < 80 ;
2026-10-14 16:03:46 -07:00
let contextChanged = false ;
2026-10-15 09:40:30 -07:00
headImpl . onEvents ? .( events );
2026-10-14 09:40:12 -07:00
for ( const ev of events ) {
2026-10-14 16:03:46 -07:00
const p = ev . payload ;
2026-10-14 19:35:34 -07:00
if ( ev . kind === "agent.message" || ev . kind === "turn.ended" || ev . kind === "user.message" ) {
dropStream ();
}
2026-10-14 22:50:13 -07:00
if ( ev . kind === "user.message" && ev . actor !== "byre" ) {
confirmPending ( String ( p [ "text" ] ?? "" ));
}
2026-10-14 16:03:46 -07:00
if ( ev . kind === "session.context" && ev . agent_session_id ) {
const prev = context . get ( ev . agent_session_id );
const window = Number ( p [ "window" ]) || prev ? . window || 0 ;
context . set ( ev . agent_session_id , { used : Number ( p [ "used" ]), window , model : String ( p [ "model" ] ?? "" ) });
contextChanged = true ;
continue ;
}
2026-10-15 00:08:32 -07:00
if ( ev . kind === "snapshot.created" ) {
placeSnapshot ( p );
continue ;
}
2026-10-14 16:03:46 -07:00
if ( ev . kind === "agent.tool_call" ) {
2026-10-15 00:08:32 -07:00
const el = renderToolCall ( p , ctx );
addToRun ( el , { name : String ( p [ "name" ]), target : toolTarget ( p [ "input" ], ctx . root ) });
addAnchor ( ev . seq , el . querySelector ( "summary" ), false );
2026-10-14 16:03:46 -07:00
continue ;
}
const el = renderEvent ( ev , ctx );
if ( ! el ) {
continue ;
}
if ( ev . kind === "agent.thinking" ) {
addToRun ( el , undefined );
continue ;
2026-10-14 09:40:12 -07:00
}
2026-10-14 16:03:46 -07:00
ctx . run = undefined ;
2026-10-15 10:30:24 -07:00
if ( ev . kind === "turn.ended" && p [ "handoff" ] === true && ctx . handoff ) {
addAnchor ( ev . seq , closeHandoff ( el ), true );
2026-10-14 19:35:34 -07:00
continue ;
}
2026-10-15 10:30:24 -07:00
if ( ev . kind === "turn.ended" || ( ev . kind === "user.message" && p [ "mid_turn" ] !== true )) {
closeHandoff ( undefined );
}
if ( ev . kind === "user.message" && p [ "handoff" ] === true ) {
openHandoff ();
}
2026-10-15 09:59:13 -07:00
if ( ev . kind === "turn.ended" && ctx . quiet && ctx . turnStart ? . parentNode === transcript ) {
2026-10-15 10:21:34 -07:00
let node = ctx . turnStart ;
while ( node && node !== spinner ) {
const next = node . nextSibling ;
node . remove ();
node = next ;
}
2026-10-15 09:59:13 -07:00
ctx . turnStart = undefined ;
ctx . quiet = false ;
continue ;
}
2026-10-14 16:03:46 -07:00
put ( el );
2026-10-15 10:20:23 -07:00
if ( ev . kind === "user.message" && p [ "mid_turn" ] !== true ) {
2026-10-14 19:35:34 -07:00
ctx . turnStart = el ;
}
2026-10-15 00:08:32 -07:00
if ( ev . kind === "turn.ended" || ev . kind === "inbox.created" ) {
addAnchor ( ev . seq , el , ev . kind === "turn.ended" );
}
2026-10-14 16:03:46 -07:00
}
if ( contextChanged ) {
renderHead ();
2026-10-15 09:40:30 -07:00
const sess = src . session ();
2026-10-14 19:35:34 -07:00
if ( sess && ! sess . model && ( context . get ( sess . id ) ? . model ?? "" ) !== composerModel ) {
renderComposer ();
}
2026-10-14 09:40:12 -07:00
}
if ( pinned ) {
transcript . scrollTop = transcript . scrollHeight ;
}
};
const renderAll = () => {
2026-10-14 16:03:46 -07:00
observer . disconnect ();
visibleReports . clear ();
2026-10-14 19:35:34 -07:00
dropStream ();
2026-10-14 22:50:13 -07:00
pending = [];
2026-10-14 16:03:46 -07:00
ctx = newCtx ();
context = new Map ();
2026-10-14 22:42:46 -07:00
focusStart = true ;
2026-10-15 09:40:30 -07:00
headImpl . reset ? .();
2026-10-14 09:40:12 -07:00
renderHead ();
2026-10-15 09:40:30 -07:00
if ( ! src . key ()) {
2026-10-14 19:35:34 -07:00
renderComposer ();
2026-10-15 09:40:30 -07:00
replace ( transcript , h ( "div" , { class : "empty" }, src . empty ));
2026-10-14 09:40:12 -07:00
return ;
}
2026-10-14 16:03:46 -07:00
replace ( transcript , spinner );
2026-10-15 09:40:30 -07:00
appendEvents ( src . events ());
2026-10-14 19:35:34 -07:00
renderComposer ();
2026-10-14 09:40:12 -07:00
};
const offs = [
2026-10-15 09:40:30 -07:00
onEach ( src . resetTopics , renderAll ),
onEach ( src . refreshTopics , () => {
2026-10-14 09:40:12 -07:00
renderHead ();
renderComposer ();
}),
2026-10-15 09:40:30 -07:00
onEach ([ src . transcriptTopic ], ( fresh ) => appendEvents ( fresh )),
onEach ([ src . streamTopic ], ( ev ) => appendDelta ( ev )),
2026-10-14 16:03:46 -07:00
onEach ([ "inbox" ], ackVisible ),
2026-10-14 09:40:12 -07:00
];
2026-10-14 16:03:46 -07:00
document . addEventListener ( "visibilitychange" , ackVisible );
2026-10-14 09:40:12 -07:00
replace ( root , h ( "div" , { class : "transcript-view" }, head , transcript , composer ));
renderAll ();
return () => {
for ( const off of offs ) {
off ();
}
2026-10-14 16:03:46 -07:00
document . removeEventListener ( "visibilitychange" , ackVisible );
2026-10-15 09:40:30 -07:00
headImpl . dispose ? .();
2026-10-14 16:03:46 -07:00
observer . disconnect ();
2026-10-14 19:35:34 -07:00
dropStream ();
2026-10-14 09:40:12 -07:00
};
}
2026-10-14 16:03:46 -07:00
const composerDrafts = new Map ();
function keepDraft ( input , key ) {
input . value = composerDrafts . get ( key ) ?? "" ;
input . addEventListener ( "input" , () => {
if ( input . value ) {
composerDrafts . set ( key , input . value );
}
else {
composerDrafts . delete ( key );
}
});
return () => composerDrafts . delete ( key );
}
2026-10-14 20:07:26 -07:00
const imageDrafts = new Map ();
const imageTypes = [ "image/png" , "image/jpeg" , "image/gif" , "image/webp" ];
const maxImageBytes = 5 << 20 ;
function imageTray ( form , input , key ) {
const el = h ( "div" , { class : "compose-images" });
const render = () => {
const imgs = imageDrafts . get ( key ) ?? [];
el . hidden = imgs . length === 0 ;
replace ( el , ... imgs . map (( img ) => {
const remove = h ( "button" , { type : "button" , class : "remove" , title : "Remove image" }, "× " );
remove . onclick = () => {
URL . revokeObjectURL ( img . url );
const rest = ( imageDrafts . get ( key ) ?? []). filter (( i ) => i !== img );
if ( rest . length > 0 ) {
imageDrafts . set ( key , rest );
}
else {
imageDrafts . delete ( key );
}
render ();
};
return h ( "div" , { class : "thumb" }, h ( "img" , { src : img . url , alt : "attached image" }), remove );
}));
};
const add = ( files ) => {
const added = [];
for ( const f of files ) {
if ( ! imageTypes . includes ( f . type )) {
2026-10-14 22:42:46 -07:00
void showAlert ( ` ${ f . name || "That file" } isn't a PNG, JPEG, GIF or WebP image.` );
2026-10-14 20:07:26 -07:00
continue ;
}
if ( f . size > maxImageBytes ) {
2026-10-14 22:42:46 -07:00
void showAlert ( ` ${ f . name || "That image" } is over ${ maxImageBytes >> 20 } MiB.` );
2026-10-14 20:07:26 -07:00
continue ;
}
added . push ({ file : f , url : URL . createObjectURL ( f ) });
}
if ( added . length === 0 ) {
return false ;
}
imageDrafts . set ( key , [...( imageDrafts . get ( key ) ?? []), ... added ]);
render ();
return true ;
};
input . addEventListener ( "paste" , ( e ) => {
const files = [...( e . clipboardData ? . files ?? [])]. filter (( f ) => f . type . startsWith ( "image/" ));
if ( files . length > 0 ) {
e . preventDefault ();
add ( files );
}
});
form . addEventListener ( "dragover" , ( e ) => {
if ( e . dataTransfer ? . types . includes ( "Files" )) {
e . preventDefault ();
form . classList . add ( "dropping" );
}
});
form . addEventListener ( "dragleave" , ( e ) => {
if ( ! form . contains ( e . relatedTarget )) {
form . classList . remove ( "dropping" );
}
});
form . addEventListener ( "drop" , ( e ) => {
form . classList . remove ( "dropping" );
const files = [...( e . dataTransfer ? . files ?? [])];
if ( files . length > 0 ) {
e . preventDefault ();
add ( files );
}
});
render ();
return {
el ,
take : () => {
const imgs = imageDrafts . get ( key ) ?? [];
imageDrafts . delete ( key );
render ();
return imgs ;
},
restore : ( imgs ) => {
if ( imgs . length > 0 ) {
imageDrafts . set ( key , [... imgs , ...( imageDrafts . get ( key ) ?? [])]);
}
render ();
},
};
}
async function uploadImages ( imgs ) {
if ( imgs . length === 0 ) {
return undefined ;
}
const ids = [];
for ( const img of imgs ) {
const blob = await post ( "/api/blobs" , { body : img . file });
ids . push ( blob . id );
}
return ids ;
}
function releaseImages ( imgs ) {
for ( const img of imgs ) {
URL . revokeObjectURL ( img . url );
}
}
2026-10-14 19:35:34 -07:00
const modelChoices = new Map ();
function modelSelect ( key , current ) {
const options = current && ! models . includes ( current ) ? [ current , ... models ] : models ;
const select = h ( "select" , { title : "Model for the next turn" }, ... options . map (( m ) => h ( "option" , { value : m }, m )));
select . value = modelChoices . get ( key ) ?? ( current || models [ 0 ]);
select . onchange = () => {
modelChoices . set ( key , select . value );
};
return select ;
}
2026-10-15 09:40:30 -07:00
function composeForm ( src , key , sessionId , sessionState , currentModel , showPending ) {
const text = h ( "textarea" , { placeholder : src . placeholder ( sessionState ), rows : 3 });
const clearDraft = keepDraft ( text , `message: ${ key } ` );
const send = h ( "button" , { type : "submit" , disabled : ! src . canSend ( sessionState ) }, "Send" );
2026-10-14 09:40:12 -07:00
const interrupt = h ( "button" , { type : "button" , class : "danger" , disabled : sessionState !== "running" && sessionState !== "queued" }, sessionState === "queued" ? "Cancel queued turn" : "Interrupt" );
interrupt . onclick = async () => {
2026-10-14 22:50:13 -07:00
if ( ! sessionId ) {
return ;
}
2026-10-14 09:40:12 -07:00
try {
await post ( "/api/sessions/{sessionId}/interrupt" , { path : { sessionId } });
}
catch ( err ) {
2026-10-14 22:42:46 -07:00
void showAlert ( errorText ( err ));
2026-10-14 09:40:12 -07:00
}
};
2026-10-15 09:40:30 -07:00
const modelKey = `message: ${ sessionId ?? key } ` ;
2026-10-14 22:50:13 -07:00
const model = modelSelect ( modelKey , currentModel );
2026-10-14 20:07:26 -07:00
const form = h ( "form" , { class : "compose" });
2026-10-15 09:40:30 -07:00
const tray = imageTray ( form , text , `message: ${ key } ` );
2026-10-14 20:07:26 -07:00
form . append ( tray . el , text , h ( "div" , { class : "compose-actions" }, model , interrupt , send ));
2026-10-14 09:40:12 -07:00
form . onsubmit = async ( e ) => {
e . preventDefault ();
2026-10-14 19:35:34 -07:00
const value = text . value ;
2026-10-15 09:40:30 -07:00
if ( ! value . trim () && ! imageDrafts . has ( `message: ${ key } ` )) {
2026-10-14 09:40:12 -07:00
return ;
}
2026-10-14 20:07:26 -07:00
const imgs = tray . take ();
2026-10-14 19:35:34 -07:00
text . value = "" ;
clearDraft ();
2026-10-14 22:50:13 -07:00
const cancel = showPending ( value , imgs );
2026-10-14 09:40:12 -07:00
try {
2026-10-14 20:07:26 -07:00
const images = await uploadImages ( imgs );
2026-10-15 09:40:30 -07:00
await src . send ({ key , sessionId , text : value , images , model : model . value !== currentModel ? model . value : undefined , firstModel : model . value });
2026-10-14 22:50:13 -07:00
modelChoices . delete ( modelKey );
2026-10-14 09:40:12 -07:00
}
catch ( err ) {
2026-10-14 22:50:13 -07:00
cancel ();
2026-10-15 09:40:30 -07:00
composerDrafts . set ( `message: ${ key } ` , value );
2026-10-14 19:35:34 -07:00
text . value = value ;
2026-10-14 20:07:26 -07:00
tray . restore ( imgs );
2026-10-14 22:42:46 -07:00
void showAlert ( errorText ( err ));
2026-10-14 09:40:12 -07:00
}
};
2026-10-14 16:03:46 -07:00
submitOnEnter ( text , form );
2026-10-14 09:40:12 -07:00
return form ;
}
2026-10-14 16:03:46 -07:00
function submitOnEnter ( input , form ) {
2026-10-14 09:40:12 -07:00
input . onkeydown = ( e ) => {
2026-10-14 16:03:46 -07:00
if ( e . key === "Enter" && ! e . shiftKey && ! e . isComposing ) {
2026-10-14 09:40:12 -07:00
e . preventDefault ();
form . requestSubmit ();
}
};
}
2026-10-15 09:40:30 -07:00
export function compactButton ( sessionId , sessionState , c ) {
2026-10-14 23:38:17 -07:00
const pct = c && c . window > 0 ? Math . min ( 100 , ( c . used / c . window ) * 100 ) : 0 ;
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "ok" ;
const usage = c && c . window > 0 ? `\ncontext: ${ c . used . toLocaleString () } of ${ c . window . toLocaleString () } tokens ${ c . model ? ` ( ${ c . model } )` : "" } ` : "" ;
const b = h ( "button" , {
type : "button" ,
class : `compact ${ level } ` ,
style : `--fill: ${ pct } %` ,
disabled : ! sessionId || sessionState !== "idle" ,
title : `Ask the agent for a handoff, then continue in a new session with a fresh context ${ usage } ` ,
}, "Compact" );
2026-10-14 19:35:34 -07:00
b . onclick = async () => {
2026-10-14 23:38:17 -07:00
if ( ! sessionId ) {
return ;
}
2026-10-14 19:35:34 -07:00
b . disabled = true ;
try {
await post ( "/api/sessions/{sessionId}/compact" , { path : { sessionId } });
}
catch ( err ) {
2026-10-14 22:42:46 -07:00
void showAlert ( errorText ( err ));
2026-10-14 19:35:34 -07:00
b . disabled = false ;
}
};
return b ;
}
2026-10-14 16:03:46 -07:00
function renderToolCall ( p , ctx ) {
const result = h ( "div" , { class : "tool-result pending" }, "running…" );
ctx . tools . set ( String ( p [ "id" ]), result );
return h ( "details" , { class : "tool" }, h ( "summary" , {}, h ( "span" , { class : "tool-name" }, String ( p [ "name" ])), " " , h ( "span" , { class : "tool-brief" }, toolBrief ( p [ "input" ], ctx . root ))), h ( "pre" , { class : "tool-input" }, JSON . stringify ( p [ "input" ], null , 2 )), result );
}
2026-10-15 00:08:32 -07:00
function snapMark ( ns ) {
if ( ns . length === 0 ) {
return null ;
}
const lo = Math . min (... ns );
const hi = Math . max (... ns );
const b = h ( "button" , { type : "button" , class : "snap-mark" , title : `Show snapshot ${ hi } in the Changes tab` }, lo === hi ? `# ${ hi } ` : `# ${ lo } – #${ hi } ` );
b . onclick = ( e ) => {
e . preventDefault ();
e . stopPropagation ();
pinSnapshot ( hi );
2026-10-15 09:40:30 -07:00
focusTab ( "changes" );
2026-10-15 00:08:32 -07:00
};
return b ;
}
2026-10-14 16:03:46 -07:00
function runSummary ( calls ) {
const counts = new Map ();
for ( const c of calls ) {
const key = ` ${ c . name } \0 ${ c . target } ` ;
const entry = counts . get ( key );
if ( entry ) {
entry . n ++ ;
}
else {
counts . set ( key , { call : c , n : 1 });
}
}
if ( counts . size === 0 ) {
return h ( "span" , { class : "tool-brief" }, "thinking" );
}
const parts = [];
for ( const { call , n } of counts . values ()) {
if ( parts . length > 0 ) {
parts . push ( ", " );
}
parts . push ( h ( "span" , {}, h ( "span" , { class : "tool-name" }, call . name ), call . target ? h ( "span" , { class : "tool-brief" }, " " , call . target ) : null , n > 1 ? h ( "span" , { class : "tool-count" }, ` × ${ n } ` ) : null ));
}
return h ( "span" , {}, ... parts );
}
function renderEvent ( ev , ctx ) {
2026-10-14 09:40:12 -07:00
const p = ev . payload ;
2026-10-15 07:27:17 -07:00
const md = { taskId : ev . task_id };
2026-10-14 09:40:12 -07:00
const when = h ( "time" , { dateTime : ev . created_at , title : ev . created_at }, ago ( ev . created_at ));
switch ( ev . kind ) {
case "user.message" :
2026-10-14 19:35:34 -07:00
ctx . lastMessage = undefined ;
2026-10-15 09:59:13 -07:00
ctx . quiet = false ;
2026-10-14 16:03:46 -07:00
if ( ev . actor === "byre" ) {
2026-10-14 19:35:34 -07:00
const text = String ( p [ "text" ]);
2026-10-14 23:40:32 -07:00
const first = text . split ( "\n" )[ 0 ] ?? "" ;
const brief = first . length > 120 ? ` ${ first . slice ( 0 , 120 ) } …` : first ;
2026-10-15 07:27:17 -07:00
return h ( "details" , { class : "tool byre-prompt" }, h ( "summary" , {}, h ( "span" , { class : "tool-name" }, "byre" ), " " , h ( "span" , { class : "tool-brief" }, brief ), " " , when ), markdown ( text , "text md" , md ));
2026-10-14 16:03:46 -07:00
}
2026-10-15 07:27:17 -07:00
return h ( "div" , { class : "msg user" }, h ( "div" , { class : "who" }, ev . actor , " " , when ), messageImages ( p [ "images" ]), String ( p [ "text" ] ?? "" ). trim () ? markdown ( String ( p [ "text" ]), "text md" , md ) : null );
2026-10-14 09:40:12 -07:00
case "agent.message" :
2026-10-15 09:59:13 -07:00
if ( String ( p [ "text" ]). trim () === quietReply ) {
ctx . quiet = true ;
return null ;
}
ctx . quiet = false ;
2026-10-15 07:27:17 -07:00
ctx . lastMessage = h ( "div" , { class : "msg agent" }, markdown ( String ( p [ "text" ]), "text md" , md ));
2026-10-14 19:35:34 -07:00
return ctx . lastMessage ;
2026-10-14 09:40:12 -07:00
case "agent.thinking" :
2026-10-15 07:27:17 -07:00
return h ( "details" , { class : "thinking" }, h ( "summary" , {}, "thinking" ), markdown ( String ( p [ "text" ]), "text md" , md ));
2026-10-14 09:40:12 -07:00
case "agent.tool_result" : {
2026-10-14 16:03:46 -07:00
const target = ctx . tools . get ( String ( p [ "tool_use_id" ]));
2026-10-14 09:40:12 -07:00
const body = h ( "pre" , {}, resultText ( p [ "content" ]));
if ( ! target ) {
return h ( "div" , { class : "tool-result orphan" }, body );
}
2026-10-14 21:16:05 -07:00
target . className = "tool-result" ;
2026-10-14 09:40:12 -07:00
replace ( target , body );
return null ;
}
case "turn.ended" :
2026-10-14 19:35:34 -07:00
if ( typeof p [ "inbox_item_id" ] === "string" && p [ "inbox_item_id" ] && ctx . lastMessage ) {
ctx . watchReport ( ctx . lastMessage , p [ "inbox_item_id" ]);
}
if ( p [ "handoff" ] === true ) {
return h ( "div" , { class : "note" }, "handoff written; continuing in a new session with a fresh context " , when );
}
2026-10-14 09:40:12 -07:00
return h ( "div" , { class : `turn-end ${ p [ "is_error" ] ? "error" : "" } ` }, p [ "is_error" ] ? `turn ended: ${ String ( p [ "subtype" ]). replaceAll ( "_" , " " ) } ` : "" );
case "session.created" :
return h ( "div" , { class : "note" }, ` ${ ev . actor } started a ${ p [ "vendor" ] } session` , p [ "model" ] ? ` ( ${ p [ "model" ] } )` : "" , " " , when );
2026-10-14 19:35:34 -07:00
case "session.model" :
return h ( "div" , { class : "note" }, ` ${ ev . actor } switched the model to ${ p [ "model" ] } ` , when );
2026-10-14 09:40:12 -07:00
case "session.state" :
return p [ "error" ] ? h ( "div" , { class : "note error" }, `session ${ p [ "state" ] } : ${ p [ "error" ] } ` ) : null ;
case "inbox.created" : {
const kind = p [ "kind" ];
if ( kind === "question" ) {
const q = p [ "payload" ];
return h ( "div" , { class : "note ask" }, `asked ${ p [ "blocking" ] ? " (blocking)" : "" } : ` , String ( q [ "question" ]));
}
if ( kind === "session_error" || kind === "rate_limited" ) {
const q = p [ "payload" ];
return h ( "div" , { class : "note error" }, String ( q [ "error" ]));
}
return null ;
}
2026-10-14 21:21:54 -07:00
case "task.merge_title" :
return h ( "div" , { class : "note" }, ` ${ ev . actor } set the merge commit title to " ${ String ( p [ "merge_title" ]) } " ` , when );
2026-10-14 19:35:34 -07:00
case "task.merged" :
2026-10-14 22:45:02 -07:00
return h ( "div" , { class : "note" }, ` ${ ev . actor } merged the task ${ p [ "commit" ] ? ` as ${ String ( p [ "commit" ]). slice ( 0 , 7 ) } ` : "" }${ p [ "pushed" ] ? ` and pushed it to ${ String ( p [ "pushed" ]) } ` : "" } ` , when );
2026-10-14 19:35:34 -07:00
case "task.discarded" :
return h ( "div" , { class : "note" }, ` ${ ev . actor } discarded the task ` , when );
2026-10-14 09:40:12 -07:00
}
return null ;
}
2026-10-14 20:07:26 -07:00
function messageImages ( ids ) {
if ( ! Array . isArray ( ids ) || ids . length === 0 ) {
return null ;
}
return h ( "div" , { class : "msg-images" }, ... ids . map (( id ) => {
const src = `/api/blobs/ ${ encodeURIComponent ( String ( id )) } ` ;
return h ( "a" , { href : src , target : "_blank" , rel : "noopener" }, h ( "img" , { src , alt : "attached image" , loading : "lazy" }));
}));
}
2026-10-15 07:15:51 -07:00
function mergeQuestion ( title , target , coAuthors ) {
const lines = [ `Merge " ${ title } " into ${ target } ?` ];
const credited = coAuthors . filter (( p ) => p . git_name && p . git_email );
const missing = coAuthors . filter (( p ) => ! p . git_name || ! p . git_email );
if ( credited . length > 0 ) {
lines . push ( "" , "Co-authored-by:" , ... credited . map (( p ) => ` ${ p . git_name } < ${ p . git_email } >` ));
}
if ( missing . length > 0 ) {
lines . push ( "" , "No git identity, so no trailer:" , ... missing . map (( p ) => ` ${ p . display_name } ( ${ p . login } )` ));
}
return lines . join ( "\n" );
}
2026-10-14 22:42:46 -07:00
async function confirmThen ( question , action , danger , act ) {
if ( ! ( await showConfirm ( question , action , danger ))) {
return ;
}
2026-10-14 19:35:34 -07:00
try {
2026-10-14 21:01:38 -07:00
await act ();
2026-10-14 19:35:34 -07:00
}
catch ( err ) {
2026-10-14 22:42:46 -07:00
await showAlert ( errorText ( err ));
2026-10-14 19:35:34 -07:00
}
}
2026-10-14 16:03:46 -07:00
function toolBrief ( input , root ) {
2026-10-14 09:40:12 -07:00
if ( typeof input !== "object" || input === null ) {
return "" ;
}
const i = input ;
const brief = i [ "command" ] ?? i [ "file_path" ] ?? i [ "pattern" ] ?? i [ "url" ] ?? i [ "question" ] ?? i [ "description" ] ?? "" ;
2026-10-14 16:03:46 -07:00
const text = relative ( String ( brief ), root );
2026-10-14 09:40:12 -07:00
return text . length > 120 ? ` ${ text . slice ( 0 , 120 ) } …` : text ;
}
2026-10-14 16:03:46 -07:00
function toolTarget ( input , root ) {
if ( typeof input !== "object" || input === null ) {
return "" ;
}
const path = input [ "file_path" ];
return typeof path === "string" ? relative ( path , root ) : "" ;
}
function relative ( path , root ) {
return root && path . startsWith ( ` ${ root } /` ) ? path . slice ( root . length + 1 ) : path ;
}
2026-10-14 09:40:12 -07:00
function resultText ( content ) {
if ( typeof content === "string" ) {
return content ;
}
if ( Array . isArray ( content )) {
return content . map (( c ) => ( typeof c === "object" && c !== null && "text" in c ? String ( c . text ) : JSON . stringify ( c ))). join ( "\n" );
}
return JSON . stringify ( content , null , 2 );
}
//# sourceMappingURL=task.js.map