1- import { memo , useEffect , useRef , useState } from "react" ;
1+ import { memo , useEffect , useMemo , useRef , useState } from "react" ;
22import { useNavigate , useParams } from "react-router-dom" ;
33import {
44 Send ,
@@ -867,6 +867,44 @@ export default function Chat() {
867867 // process thinking a generation is still in flight forever.
868868 window . api . app . setBusy ( true ) ;
869869
870+ // Models often emit many tiny chunks per second. Updating React for
871+ // every token makes Markdown parsing and layout dominate the UI thread,
872+ // so coalesce chunks into a steady ~30 FPS stream instead.
873+ let pendingText = "" ;
874+ let pendingUsage : UsageInfo | undefined ;
875+ let pendingToolCalls : ToolCall [ ] = [ ] ;
876+ let flushTimer : number | null = null ;
877+ const flushStream = ( ) => {
878+ flushTimer = null ;
879+ if ( ! pendingText && ! pendingUsage && pendingToolCalls . length === 0 ) return ;
880+ const text = pendingText ;
881+ const usage = pendingUsage ;
882+ const toolCalls = pendingToolCalls ;
883+ pendingText = "" ;
884+ pendingUsage = undefined ;
885+ pendingToolCalls = [ ] ;
886+ setMessages ( ( current ) => {
887+ const next = [ ...current ] ;
888+ const last = next [ next . length - 1 ] ;
889+ next [ next . length - 1 ] = {
890+ role : "assistant" ,
891+ content : last . content + text ,
892+ usage : usage
893+ ? {
894+ promptTokens : usage . promptTokens ?? last . usage ?. promptTokens ,
895+ completionTokens : usage . completionTokens ?? last . usage ?. completionTokens ,
896+ elapsedMs : Date . now ( ) - streamStartedAt ,
897+ }
898+ : last . usage ,
899+ toolCalls : toolCalls . length > 0 ? [ ...( last . toolCalls ?? [ ] ) , ...toolCalls ] : last . toolCalls ,
900+ } ;
901+ return next ;
902+ } ) ;
903+ } ;
904+ const scheduleFlush = ( ) => {
905+ if ( flushTimer === null ) flushTimer = window . setTimeout ( flushStream , 32 ) ;
906+ } ;
907+
870908 const { requestId, promise } = window . api . chat . send (
871909 parsed . provider ,
872910 parsed . modelId ,
@@ -875,28 +913,17 @@ export default function Chat() {
875913 ( chunk ) => {
876914 const piece = chunk . message ?. content ?? "" ;
877915 if ( ! piece && ! chunk . usage && ! chunk . toolCalls ) return ;
878- setMessages ( ( m ) => {
879- const next = [ ...m ] ;
880- const last = next [ next . length - 1 ] ;
881- next [ next . length - 1 ] = {
882- role : "assistant" ,
883- content : last . content + piece ,
884- usage : chunk . usage
885- ? {
886- promptTokens : chunk . usage . promptTokens ?? last . usage ?. promptTokens ,
887- completionTokens : chunk . usage . completionTokens ?? last . usage ?. completionTokens ,
888- elapsedMs : Date . now ( ) - streamStartedAt ,
889- }
890- : last . usage ,
891- toolCalls : chunk . toolCalls ? [ ...( last . toolCalls ?? [ ] ) , ...chunk . toolCalls ] : last . toolCalls ,
892- } ;
893- return next ;
894- } ) ;
916+ pendingText += piece ;
917+ if ( chunk . usage ) pendingUsage = { ...pendingUsage , ...chunk . usage } ;
918+ if ( chunk . toolCalls ) pendingToolCalls . push ( ...chunk . toolCalls ) ;
919+ scheduleFlush ( ) ;
895920 } ,
896921 agentMode && ! ! agentWorkspace
897922 ) ;
898923 setActiveRequestId ( requestId ) ;
899924 const result = await promise ;
925+ if ( flushTimer !== null ) window . clearTimeout ( flushTimer ) ;
926+ flushStream ( ) ;
900927 setActiveRequestId ( null ) ;
901928
902929 // One silent retry for errors that usually clear on their own
@@ -1252,6 +1279,30 @@ export default function Chat() {
12521279 }
12531280 }
12541281
1282+ const parsedModel = useMemo ( ( ) => parseModelRef ( model ) , [ model ] ) ;
1283+ const { individualAttachments, folderGroups } = useMemo ( ( ) => {
1284+ const individual = attachments . filter ( ( file ) => ! file . folder ) ;
1285+ const counts = new Map < string , number > ( ) ;
1286+ for ( const file of attachments ) {
1287+ if ( file . folder ) counts . set ( file . folder , ( counts . get ( file . folder ) ?? 0 ) + 1 ) ;
1288+ }
1289+ return {
1290+ individualAttachments : individual ,
1291+ folderGroups : [ ...counts ] . map ( ( [ folder , count ] ) => ( { folder, count } ) ) ,
1292+ } ;
1293+ } , [ attachments ] ) ;
1294+ const lastAssistantIndex = useMemo ( ( ) => messages . findLastIndex ( ( message ) => message . role === "assistant" ) , [ messages ] ) ;
1295+ const sessionCost = useMemo (
1296+ ( ) =>
1297+ parsedModel && parsedModel . provider !== "ollama" && parsedModel . provider !== "llamacpp"
1298+ ? messages . reduce ( ( sum , message ) => {
1299+ if ( message . role !== "assistant" || ! message . usage ) return sum ;
1300+ return sum + ( estimateCost ( parsedModel . modelId , message . usage . promptTokens , message . usage . completionTokens ) ?? 0 ) ;
1301+ } , 0 )
1302+ : 0 ,
1303+ [ messages , parsedModel ]
1304+ ) ;
1305+
12551306 if ( ! hasApi ) {
12561307 return (
12571308 < div className = "flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground" >
@@ -1261,23 +1312,8 @@ export default function Chat() {
12611312 ) ;
12621313 }
12631314
1264- const parsedModel = parseModelRef ( model ) ;
1265- const individualAttachments = attachments . filter ( ( f ) => ! f . folder ) ;
1266- const folderGroups = Array . from ( new Set ( attachments . filter ( ( f ) => f . folder ) . map ( ( f ) => f . folder ! ) ) ) . map (
1267- ( folder ) => ( { folder, count : attachments . filter ( ( f ) => f . folder === folder ) . length } )
1268- ) ;
1269- const lastAssistantIndex = [ ...messages ] . map ( ( m ) => m . role ) . lastIndexOf ( "assistant" ) ;
12701315 const currentProject = getCurrentProject ( ) ;
12711316
1272- const sessionCost =
1273- parsedModel && parsedModel . provider !== "ollama" && parsedModel . provider !== "llamacpp"
1274- ? messages . reduce ( ( sum , m ) => {
1275- if ( m . role !== "assistant" || ! m . usage ) return sum ;
1276- const cost = estimateCost ( parsedModel . modelId , m . usage . promptTokens , m . usage . completionTokens ) ;
1277- return sum + ( cost ?? 0 ) ;
1278- } , 0 )
1279- : 0 ;
1280-
12811317 return (
12821318 < div className = "flex h-full flex-col bg-background/35" >
12831319 < div className = "surface-glass flex min-h-14 flex-wrap items-center gap-2 border-b border-border/70 px-4 py-2.5 pl-14 shadow-sm md:pl-4" >
0 commit comments