@@ -15,6 +15,11 @@ let jobListRefreshTimer = null;
1515const JOB_EVENTS_CAP = 500 ;
1616const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100 ;
1717
18+ // --- Tool Activity State ---
19+ let _activeGroup = null ;
20+ let _activeToolCards = { } ;
21+ let _activityThinking = null ;
22+
1823// --- Auth ---
1924
2025function authenticate ( ) {
@@ -113,6 +118,7 @@ function connectSSE() {
113118 document . getElementById ( 'sse-dot' ) . classList . remove ( 'disconnected' ) ;
114119 document . getElementById ( 'sse-status' ) . textContent = 'Connected' ;
115120 if ( sseHasConnectedBefore && currentThreadId ) {
121+ finalizeActivityGroup ( ) ;
116122 loadHistory ( ) ;
117123 }
118124 sseHasConnectedBefore = true ;
@@ -126,6 +132,7 @@ function connectSSE() {
126132 eventSource . addEventListener ( 'response' , ( e ) => {
127133 const data = JSON . parse ( e . data ) ;
128134 if ( ! isCurrentThread ( data . thread_id ) ) return ;
135+ finalizeActivityGroup ( ) ;
129136 addMessage ( 'assistant' , data . content ) ;
130137 setStatus ( '' ) ;
131138 enableChatInput ( ) ;
@@ -136,25 +143,31 @@ function connectSSE() {
136143 eventSource . addEventListener ( 'thinking' , ( e ) => {
137144 const data = JSON . parse ( e . data ) ;
138145 if ( ! isCurrentThread ( data . thread_id ) ) return ;
139- setStatus ( data . message , true ) ;
146+ showActivityThinking ( data . message ) ;
140147 } ) ;
141148
142149 eventSource . addEventListener ( 'tool_started' , ( e ) => {
143150 const data = JSON . parse ( e . data ) ;
144151 if ( ! isCurrentThread ( data . thread_id ) ) return ;
145- setStatus ( 'Running tool: ' + data . name , true ) ;
152+ addToolCard ( data . name ) ;
146153 } ) ;
147154
148155 eventSource . addEventListener ( 'tool_completed' , ( e ) => {
149156 const data = JSON . parse ( e . data ) ;
150157 if ( ! isCurrentThread ( data . thread_id ) ) return ;
151- const icon = data . success ? '\u2713' : '\u2717' ;
152- setStatus ( 'Tool ' + data . name + ' ' + icon ) ;
158+ completeToolCard ( data . name , data . success ) ;
159+ } ) ;
160+
161+ eventSource . addEventListener ( 'tool_result' , ( e ) => {
162+ const data = JSON . parse ( e . data ) ;
163+ if ( ! isCurrentThread ( data . thread_id ) ) return ;
164+ setToolCardOutput ( data . name , data . preview ) ;
153165 } ) ;
154166
155167 eventSource . addEventListener ( 'stream_chunk' , ( e ) => {
156168 const data = JSON . parse ( e . data ) ;
157169 if ( ! isCurrentThread ( data . thread_id ) ) return ;
170+ finalizeActivityGroup ( ) ;
158171 appendToLastAssistant ( data . content ) ;
159172 } ) ;
160173
@@ -166,6 +179,7 @@ function connectSSE() {
166179 // the agentic loop finished, so re-enable input as a safety net in case
167180 // the response SSE event is empty or lost.
168181 if ( data . message === 'Done' || data . message === 'Awaiting approval' ) {
182+ finalizeActivityGroup ( ) ;
169183 enableChatInput ( ) ;
170184 }
171185 } ) ;
@@ -197,6 +211,7 @@ function connectSSE() {
197211 if ( e . data ) {
198212 const data = JSON . parse ( e . data ) ;
199213 if ( ! isCurrentThread ( data . thread_id ) ) return ;
214+ finalizeActivityGroup ( ) ;
200215 addMessage ( 'system' , 'Error: ' + data . message ) ;
201216 enableChatInput ( ) ;
202217 }
@@ -256,8 +271,6 @@ function sendMessage() {
256271 addMessage ( 'user' , content ) ;
257272 input . value = '' ;
258273 autoResizeTextarea ( input ) ;
259- setStatus ( 'Sending...' , true ) ;
260-
261274 sendBtn . disabled = true ;
262275 input . disabled = true ;
263276
@@ -377,13 +390,239 @@ function appendToLastAssistant(chunk) {
377390 }
378391}
379392
380- function setStatus ( text , spinning ) {
393+ function setStatus ( text ) {
381394 const el = document . getElementById ( 'chat-status' ) ;
382395 if ( ! text ) {
383396 el . innerHTML = '' ;
384397 return ;
385398 }
386- el . innerHTML = ( spinning ? '<div class="spinner"></div>' : '' ) + escapeHtml ( text ) ;
399+ el . innerHTML = escapeHtml ( text ) ;
400+ }
401+
402+ // --- Inline Tool Activity Cards ---
403+
404+ function getOrCreateActivityGroup ( ) {
405+ if ( _activeGroup ) return _activeGroup ;
406+ const container = document . getElementById ( 'chat-messages' ) ;
407+ const group = document . createElement ( 'div' ) ;
408+ group . className = 'activity-group' ;
409+ container . appendChild ( group ) ;
410+ container . scrollTop = container . scrollHeight ;
411+ _activeGroup = group ;
412+ _activeToolCards = { } ;
413+ return group ;
414+ }
415+
416+ function showActivityThinking ( message ) {
417+ const group = getOrCreateActivityGroup ( ) ;
418+ if ( _activityThinking ) {
419+ // Already exists — just update text and un-hide
420+ _activityThinking . style . display = '' ;
421+ _activityThinking . querySelector ( '.activity-thinking-text' ) . textContent = message ;
422+ } else {
423+ _activityThinking = document . createElement ( 'div' ) ;
424+ _activityThinking . className = 'activity-thinking' ;
425+ _activityThinking . innerHTML =
426+ '<span class="activity-thinking-dots">'
427+ + '<span class="activity-thinking-dot"></span>'
428+ + '<span class="activity-thinking-dot"></span>'
429+ + '<span class="activity-thinking-dot"></span>'
430+ + '</span>'
431+ + '<span class="activity-thinking-text"></span>' ;
432+ group . appendChild ( _activityThinking ) ;
433+ _activityThinking . querySelector ( '.activity-thinking-text' ) . textContent = message ;
434+ }
435+ const container = document . getElementById ( 'chat-messages' ) ;
436+ container . scrollTop = container . scrollHeight ;
437+ }
438+
439+ function removeActivityThinking ( ) {
440+ if ( _activityThinking ) {
441+ _activityThinking . remove ( ) ;
442+ _activityThinking = null ;
443+ }
444+ }
445+
446+ function addToolCard ( name ) {
447+ // Hide thinking instead of destroying — it may reappear between tool rounds
448+ if ( _activityThinking ) _activityThinking . style . display = 'none' ;
449+ const group = getOrCreateActivityGroup ( ) ;
450+
451+ const card = document . createElement ( 'div' ) ;
452+ card . className = 'activity-tool-card' ;
453+ card . setAttribute ( 'data-tool-name' , name ) ;
454+ card . setAttribute ( 'data-status' , 'running' ) ;
455+
456+ const header = document . createElement ( 'div' ) ;
457+ header . className = 'activity-tool-header' ;
458+
459+ const icon = document . createElement ( 'span' ) ;
460+ icon . className = 'activity-tool-icon' ;
461+ icon . innerHTML = '<div class="spinner"></div>' ;
462+
463+ const toolName = document . createElement ( 'span' ) ;
464+ toolName . className = 'activity-tool-name' ;
465+ toolName . textContent = name ;
466+
467+ const duration = document . createElement ( 'span' ) ;
468+ duration . className = 'activity-tool-duration' ;
469+ duration . textContent = '' ;
470+
471+ const chevron = document . createElement ( 'span' ) ;
472+ chevron . className = 'activity-tool-chevron' ;
473+ chevron . innerHTML = '▸' ;
474+
475+ header . appendChild ( icon ) ;
476+ header . appendChild ( toolName ) ;
477+ header . appendChild ( duration ) ;
478+ header . appendChild ( chevron ) ;
479+
480+ const body = document . createElement ( 'div' ) ;
481+ body . className = 'activity-tool-body' ;
482+ body . style . display = 'none' ;
483+
484+ const output = document . createElement ( 'pre' ) ;
485+ output . className = 'activity-tool-output' ;
486+ body . appendChild ( output ) ;
487+
488+ header . addEventListener ( 'click' , ( ) => {
489+ const isOpen = body . style . display !== 'none' ;
490+ body . style . display = isOpen ? 'none' : 'block' ;
491+ chevron . classList . toggle ( 'expanded' , ! isOpen ) ;
492+ } ) ;
493+
494+ card . appendChild ( header ) ;
495+ card . appendChild ( body ) ;
496+ group . appendChild ( card ) ;
497+
498+ const startTime = Date . now ( ) ;
499+ const timerInterval = setInterval ( ( ) => {
500+ const elapsed = ( Date . now ( ) - startTime ) / 1000 ;
501+ if ( elapsed > 300 ) { clearInterval ( timerInterval ) ; return ; }
502+ duration . textContent = elapsed < 10 ? elapsed . toFixed ( 1 ) + 's' : Math . floor ( elapsed ) + 's' ;
503+ } , 100 ) ;
504+
505+ if ( ! _activeToolCards [ name ] ) _activeToolCards [ name ] = [ ] ;
506+ _activeToolCards [ name ] . push ( { card, startTime, timer : timerInterval , duration, icon, finalDuration : null } ) ;
507+
508+ const container = document . getElementById ( 'chat-messages' ) ;
509+ container . scrollTop = container . scrollHeight ;
510+ }
511+
512+ function completeToolCard ( name , success ) {
513+ const entries = _activeToolCards [ name ] ;
514+ if ( ! entries || entries . length === 0 ) return ;
515+ // Find first running card
516+ let entry = null ;
517+ for ( let i = 0 ; i < entries . length ; i ++ ) {
518+ if ( entries [ i ] . card . getAttribute ( 'data-status' ) === 'running' ) {
519+ entry = entries [ i ] ;
520+ break ;
521+ }
522+ }
523+ if ( ! entry ) entry = entries [ entries . length - 1 ] ;
524+
525+ clearInterval ( entry . timer ) ;
526+ const elapsed = ( Date . now ( ) - entry . startTime ) / 1000 ;
527+ entry . finalDuration = elapsed ;
528+ entry . duration . textContent = elapsed < 10 ? elapsed . toFixed ( 1 ) + 's' : Math . floor ( elapsed ) + 's' ;
529+ entry . icon . innerHTML = success
530+ ? '<span class="activity-icon-success">✓</span>'
531+ : '<span class="activity-icon-fail">✗</span>' ;
532+ entry . card . setAttribute ( 'data-status' , success ? 'success' : 'fail' ) ;
533+ }
534+
535+ function setToolCardOutput ( name , preview ) {
536+ const entries = _activeToolCards [ name ] ;
537+ if ( ! entries || entries . length === 0 ) return ;
538+ // Find first card with empty output
539+ let entry = null ;
540+ for ( let i = 0 ; i < entries . length ; i ++ ) {
541+ const out = entries [ i ] . card . querySelector ( '.activity-tool-output' ) ;
542+ if ( out && ! out . textContent ) {
543+ entry = entries [ i ] ;
544+ break ;
545+ }
546+ }
547+ if ( ! entry ) entry = entries [ entries . length - 1 ] ;
548+
549+ const output = entry . card . querySelector ( '.activity-tool-output' ) ;
550+ if ( output ) {
551+ const truncated = preview . length > 2000 ? preview . substring ( 0 , 2000 ) + '\n... (truncated)' : preview ;
552+ output . textContent = truncated ;
553+ }
554+ }
555+
556+ function finalizeActivityGroup ( ) {
557+ removeActivityThinking ( ) ;
558+ if ( ! _activeGroup ) return ;
559+
560+ // Stop all timers
561+ for ( const name in _activeToolCards ) {
562+ const entries = _activeToolCards [ name ] ;
563+ for ( let i = 0 ; i < entries . length ; i ++ ) {
564+ clearInterval ( entries [ i ] . timer ) ;
565+ }
566+ }
567+
568+ // Count tools and total duration
569+ let toolCount = 0 ;
570+ let totalDuration = 0 ;
571+ for ( const tname in _activeToolCards ) {
572+ const tentries = _activeToolCards [ tname ] ;
573+ for ( let j = 0 ; j < tentries . length ; j ++ ) {
574+ const entry = tentries [ j ] ;
575+ toolCount ++ ;
576+ if ( entry . finalDuration !== null ) {
577+ totalDuration += entry . finalDuration ;
578+ } else {
579+ // Tool was still running when finalized
580+ totalDuration += ( Date . now ( ) - entry . startTime ) / 1000 ;
581+ }
582+ }
583+ }
584+
585+ if ( toolCount === 0 ) {
586+ // No tools were used — remove the empty group
587+ _activeGroup . remove ( ) ;
588+ _activeGroup = null ;
589+ _activeToolCards = { } ;
590+ return ;
591+ }
592+
593+ // Wrap existing cards into a hidden container
594+ const cardsContainer = document . createElement ( 'div' ) ;
595+ cardsContainer . className = 'activity-cards-container' ;
596+ cardsContainer . style . display = 'none' ;
597+
598+ const cards = _activeGroup . querySelectorAll ( '.activity-tool-card' ) ;
599+ for ( let k = 0 ; k < cards . length ; k ++ ) {
600+ cardsContainer . appendChild ( cards [ k ] ) ;
601+ }
602+
603+ // Build summary line
604+ const durationStr = totalDuration < 10 ? totalDuration . toFixed ( 1 ) + 's' : Math . floor ( totalDuration ) + 's' ;
605+ const toolWord = toolCount === 1 ? 'tool' : 'tools' ;
606+ const summary = document . createElement ( 'div' ) ;
607+ summary . className = 'activity-summary' ;
608+ summary . innerHTML = '<span class="activity-summary-chevron">▸</span>'
609+ + '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'
610+ + '<span class="activity-summary-duration">(' + durationStr + ')</span>' ;
611+
612+ summary . addEventListener ( 'click' , ( ) => {
613+ const isOpen = cardsContainer . style . display !== 'none' ;
614+ cardsContainer . style . display = isOpen ? 'none' : 'block' ;
615+ summary . querySelector ( '.activity-summary-chevron' ) . classList . toggle ( 'expanded' , ! isOpen ) ;
616+ } ) ;
617+
618+ // Clear group and add summary + hidden cards
619+ _activeGroup . innerHTML = '' ;
620+ _activeGroup . classList . add ( 'collapsed' ) ;
621+ _activeGroup . appendChild ( summary ) ;
622+ _activeGroup . appendChild ( cardsContainer ) ;
623+
624+ _activeGroup = null ;
625+ _activeToolCards = { } ;
387626}
388627
389628function showApproval ( data ) {
@@ -761,6 +1000,7 @@ function loadThreads() {
7611000
7621001function switchToAssistant ( ) {
7631002 if ( ! assistantThreadId ) return ;
1003+ finalizeActivityGroup ( ) ;
7641004 currentThreadId = assistantThreadId ;
7651005 hasMore = false ;
7661006 oldestTimestamp = null ;
@@ -769,6 +1009,7 @@ function switchToAssistant() {
7691009}
7701010
7711011function switchThread ( threadId ) {
1012+ finalizeActivityGroup ( ) ;
7721013 currentThreadId = threadId ;
7731014 hasMore = false ;
7741015 oldestTimestamp = null ;
0 commit comments