11import { useState , useCallback , useRef , useEffect } from "react" ;
2- import type { UIMessage , ServerEvent , Toast , ConfigData , SessionInfo , McpServerInfo , ToolCallEntry } from "../types" ;
2+ import type { UIMessage , ServerEvent , ConfigData , SessionInfo , McpServerInfo } from "../types" ;
33import { useWebSocket } from "../hooks/useWebSocket" ;
44import { ChatView } from "./ChatView" ;
55import { MessageInput } from "./MessageInput" ;
66import { Sidebar } from "./Sidebar" ;
7- import { PermissionDialog } from "./PermissionDialog" ;
87import { ToastContainer , useToasts } from "./Toast" ;
98
109const SLASH_COMMANDS = [
@@ -22,6 +21,26 @@ const SLASH_COMMANDS = [
2221 { name : "image" , description : "Attach an image (file path or 'clipboard')" } ,
2322] ;
2423
24+ /** Always produce a new array; update last message immutably if streaming, else push new. */
25+ function updateLastOrCreate ( prev : UIMessage [ ] , update : ( msg : UIMessage ) => Partial < UIMessage > ) : UIMessage [ ] {
26+ const next = [ ...prev ] ;
27+ const last = next [ next . length - 1 ] ;
28+ if ( last ?. isStreaming ) {
29+ next [ next . length - 1 ] = { ...last , ...update ( last ) } ;
30+ } else {
31+ next . push ( {
32+ id : `assistant-${ Date . now ( ) } ` ,
33+ role : "assistant" ,
34+ content : "" ,
35+ thinking : "" ,
36+ tools : [ ] ,
37+ isStreaming : true ,
38+ ...update ( { } as UIMessage ) ,
39+ } ) ;
40+ }
41+ return next ;
42+ }
43+
2544export function App ( ) {
2645 const [ messages , setMessages ] = useState < UIMessage [ ] > ( [ ] ) ;
2746 const [ processing , setProcessing ] = useState ( false ) ;
@@ -37,10 +56,8 @@ export function App() {
3756 const [ mcpServers , setMcpServers ] = useState < McpServerInfo [ ] > ( [ ] ) ;
3857 const { toasts, addToast, removeToast } = useToasts ( ) ;
3958
40- const messagesRef = useRef ( messages ) ;
41- messagesRef . current = messages ;
42-
43- const permissionResolve = useRef < ( ( decision : string , persistRule ?: boolean ) => void ) | null > ( null ) ;
59+ // Track when the current assistant turn started (for timer)
60+ const turnStartRef = useRef < number > ( 0 ) ;
4461
4562 const handleEvent = useCallback ( ( event : ServerEvent ) => {
4663 switch ( event . type ) {
@@ -59,68 +76,44 @@ export function App() {
5976 }
6077
6178 case "user_message" : {
62- const msg : UIMessage = {
79+ setMessages ( ( prev ) => [ ... prev , {
6380 id : `user-${ Date . now ( ) } ` ,
6481 role : "user" ,
6582 content : event . text ,
66- } ;
67- setMessages ( ( prev ) => [ ...prev , msg ] ) ;
83+ } ] ) ;
6884 break ;
6985 }
7086
7187 case "assistant_start" : {
72- const msg : UIMessage = {
73- id : `assistant-${ Date . now ( ) } ` ,
74- role : "assistant" ,
75- content : "" ,
76- thinking : "" ,
77- tools : [ ] ,
78- isStreaming : true ,
79- } ;
80- setMessages ( ( prev ) => [ ...prev , msg ] ) ;
88+ turnStartRef . current = Date . now ( ) ;
8189 setProcessing ( true ) ;
8290 break ;
8391 }
8492
8593 case "thinking_delta" : {
86- setMessages ( ( prev ) => {
87- const next = [ ...prev ] ;
88- const last = next [ next . length - 1 ] ;
89- if ( last ?. isStreaming ) {
90- last . thinking = ( last . thinking ?? "" ) + event . delta ;
91- }
92- return next ;
93- } ) ;
94+ setMessages ( ( prev ) => updateLastOrCreate ( prev , ( msg ) => ( {
95+ thinking : ( msg . thinking ?? "" ) + event . delta ,
96+ } ) ) ) ;
9497 break ;
9598 }
9699
97100 case "text_delta" : {
98- setMessages ( ( prev ) => {
99- const next = [ ...prev ] ;
100- const last = next [ next . length - 1 ] ;
101- if ( last ?. isStreaming ) {
102- last . content += event . delta ;
103- }
104- return next ;
105- } ) ;
101+ setMessages ( ( prev ) => updateLastOrCreate ( prev , ( msg ) => ( {
102+ content : msg . content + event . delta ,
103+ } ) ) ) ;
106104 break ;
107105 }
108106
109107 case "tool_start" : {
110- setMessages ( ( prev ) => {
111- const next = [ ...prev ] ;
112- const last = next [ next . length - 1 ] ;
113- if ( last ?. isStreaming ) {
114- last . tools = last . tools ?? [ ] ;
115- last . tools . push ( {
116- name : event . name ,
117- args : typeof event . args === "string" ? event . args : JSON . stringify ( event . args ) . slice ( 0 , 80 ) ,
118- result : "" ,
119- isError : false ,
120- } ) ;
121- }
122- return next ;
123- } ) ;
108+ setMessages ( ( prev ) => updateLastOrCreate ( prev , ( msg ) => {
109+ const tools = [ ...( msg . tools ?? [ ] ) , {
110+ name : event . name ,
111+ args : typeof event . args === "string" ? event . args : JSON . stringify ( event . args ) . slice ( 0 , 80 ) ,
112+ result : "" ,
113+ isError : false ,
114+ } ] ;
115+ return { tools } ;
116+ } ) ) ;
124117 break ;
125118 }
126119
@@ -129,13 +122,12 @@ export function App() {
129122 const next = [ ...prev ] ;
130123 const last = next [ next . length - 1 ] ;
131124 if ( last ?. isStreaming ) {
132- last . tools = last . tools ?? [ ] ;
133- // Update the matching tool entry
134- const toolEntry = last . tools [ last . tools . length - 1 ] ;
135- if ( toolEntry && toolEntry . name === event . name && ! toolEntry . result ) {
136- toolEntry . result = event . result ;
137- toolEntry . isError = event . isError ;
138- }
125+ const tools = ( last . tools ?? [ ] ) . map ( ( t ) =>
126+ t . name === event . name && ! t . result
127+ ? { ...t , result : event . result , isError : event . isError }
128+ : t
129+ ) ;
130+ next [ next . length - 1 ] = { ...last , tools } ;
139131 }
140132 return next ;
141133 } ) ;
@@ -147,11 +139,11 @@ export function App() {
147139 const next = [ ...prev ] ;
148140 const last = next [ next . length - 1 ] ;
149141 if ( last ?. isStreaming ) {
150- last . isStreaming = false ;
142+ next [ next . length - 1 ] = { ... last , isStreaming : false } ;
151143 }
152144 return next ;
153145 } ) ;
154- setProcessing ( false ) ;
146+ // Keep processing=true — agent is still working between turns
155147 break ;
156148 }
157149
@@ -163,6 +155,7 @@ export function App() {
163155 case "error" : {
164156 addToast ( { type : "error" , text : event . text } ) ;
165157 setProcessing ( false ) ;
158+ turnStartRef . current = 0 ;
166159 break ;
167160 }
168161
@@ -252,17 +245,17 @@ export function App() {
252245 [ send ] ,
253246 ) ;
254247
255- // Request sessions and MCP state on connect
256248 useEffect ( ( ) => {
257249 if ( connected ) {
258250 handleSessionAction ( "list" ) ;
259251 handleMcpAction ( "list" ) ;
260252 }
261253 } , [ connected , handleSessionAction , handleMcpAction ] ) ;
262254
255+ const hasStreaming = messages . some ( ( m ) => m . isStreaming ) ;
256+
263257 return (
264258 < div className = "h-screen flex flex-col bg-dscode-bg" >
265- { /* Header */ }
266259 < header className = "flex items-center justify-between px-4 py-2 border-b border-dscode-border bg-dscode-surface shrink-0" >
267260 < div className = "flex items-center gap-3" >
268261 < button
@@ -293,9 +286,7 @@ export function App() {
293286 </ div >
294287 </ header >
295288
296- { /* Body */ }
297289 < div className = "flex flex-1 overflow-hidden" >
298- { /* Sidebar */ }
299290 < Sidebar
300291 open = { sidebarOpen }
301292 onClose = { ( ) => setSidebarOpen ( false ) }
@@ -309,9 +300,14 @@ export function App() {
309300 onConfigChange = { handleConfigChange }
310301 />
311302
312- { /* Main chat area */ }
313303 < main className = "flex-1 flex flex-col min-w-0" >
314- < ChatView messages = { messages } />
304+ < ChatView
305+ messages = { messages }
306+ processing = { processing }
307+ hasStreaming = { hasStreaming }
308+ permissionPrompt = { permissionPrompt }
309+ onPermission = { handlePermission }
310+ />
315311 < MessageInput
316312 onSend = { handleSend }
317313 onAbort = { handleAbort }
@@ -322,16 +318,6 @@ export function App() {
322318 </ main >
323319 </ div >
324320
325- { /* Permission Dialog */ }
326- { permissionPrompt && (
327- < PermissionDialog
328- toolName = { permissionPrompt . toolName }
329- preview = { permissionPrompt . preview }
330- onDecision = { handlePermission }
331- />
332- ) }
333-
334- { /* Toast notifications */ }
335321 < ToastContainer toasts = { toasts } onRemove = { removeToast } />
336322 </ div >
337323 ) ;
0 commit comments