@@ -666,6 +666,8 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
666666 planAdjustLabel ?: string ;
667667 /** Static badge shown in place of the "Build it" button once this plan's build has run, so it can't be re-triggered (default "Built"). */
668668 planBuiltLabel ?: string ;
669+ /** Badge shown on a plan card right after its Build-it was clicked (#2627). */
670+ planBuildingLabel ?: string ;
669671 /**
670672 * Body line of the FALLBACK confirm card shown when a propose_blueprint step
671673 * finished but produced no structured plan (so the rich card can't render).
@@ -1214,6 +1216,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
12141216 planApproveLabel = 'Build it' ,
12151217 planAdjustLabel = 'Adjust' ,
12161218 planBuiltLabel = 'Built' ,
1219+ planBuildingLabel = 'Building…' ,
12171220 planReadyLabel = 'The plan is ready. Build it now, or tell me what to adjust.' ,
12181221 planApproveMessage = 'Looks good — build it as proposed.' ,
12191222 planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.' ,
@@ -1501,13 +1504,18 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15011504 // clears any prior send-failure restore guard.
15021505 lastSubmittedRef . current = text ;
15031506 restoredErrorRef . current = null ;
1507+ // A newer send supersedes any pending approve as "the last send" — an
1508+ // unsent failure of THIS message must not roll back a plan card whose
1509+ // approval already reached the server (#2627).
1510+ lastApprovedPlanIdRef . current = null ;
15041511 onSendMessage ?.( text , files ) ;
15051512 } ,
15061513 [ onSendMessage ]
15071514 ) ;
15081515
15091516 const handleSuggestionClick = React . useCallback (
15101517 ( text : string ) => {
1518+ lastApprovedPlanIdRef . current = null ;
15111519 onSendMessage ?.( text ) ;
15121520 } ,
15131521 [ onSendMessage ]
@@ -1519,8 +1527,28 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15191527 // approval explicitly authorizes sensible defaults so a click never silently
15201528 // drops them.
15211529 const promptInputWrapRef = React . useRef < HTMLDivElement > ( null ) ;
1530+ // Optimistic per-card feedback (#2627): the approval's visible effect (a
1531+ // new user bubble + a streaming turn) lands at the BOTTOM of the thread —
1532+ // outside the viewport when the plan card is scrolled into view — so the
1533+ // card itself looked untouched for the ~10s until apply_blueprint started
1534+ // and users clicked again. Flip the clicked card to a "Building…" badge
1535+ // immediately; the durable Built state still derives from the message
1536+ // stream (builtPlanIds). A send that never left the client (rate limit /
1537+ // offline) rolls the flip back so the buttons return.
1538+ const [ approvedPlanIds , setApprovedPlanIds ] = React . useState < ReadonlySet < string > > (
1539+ ( ) => new Set < string > ( ) ,
1540+ ) ;
1541+ const lastApprovedPlanIdRef = React . useRef < string | null > ( null ) ;
15221542 const handlePlanApprove = React . useCallback (
1523- ( hasOpenQuestions : boolean ) => {
1543+ ( hasOpenQuestions : boolean , toolCallId ?: string ) => {
1544+ if ( toolCallId ) {
1545+ lastApprovedPlanIdRef . current = toolCallId ;
1546+ setApprovedPlanIds ( ( prev ) => {
1547+ const next = new Set ( prev ) ;
1548+ next . add ( toolCallId ) ;
1549+ return next ;
1550+ } ) ;
1551+ }
15241552 onSendMessage ?.( hasOpenQuestions ? planApproveDefaultsMessage : planApproveMessage ) ;
15251553 } ,
15261554 [ onSendMessage , planApproveMessage , planApproveDefaultsMessage ]
@@ -1544,6 +1572,18 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
15441572 if ( ! error || ! isUnsentSendError ( error ) ) return ;
15451573 if ( restoredErrorRef . current === error ) return ;
15461574 restoredErrorRef . current = error ;
1575+ // The approval message never left the client — roll the optimistic
1576+ // "Building…" badge back so the Build-it button returns (#2627).
1577+ if ( lastApprovedPlanIdRef . current ) {
1578+ const failedId = lastApprovedPlanIdRef . current ;
1579+ lastApprovedPlanIdRef . current = null ;
1580+ setApprovedPlanIds ( ( prev ) => {
1581+ if ( ! prev . has ( failedId ) ) return prev ;
1582+ const next = new Set ( prev ) ;
1583+ next . delete ( failedId ) ;
1584+ return next ;
1585+ } ) ;
1586+ }
15471587 const text = lastSubmittedRef . current ;
15481588 if ( ! text ) return ;
15491589 const textarea = promptInputWrapRef . current ?. querySelector ( 'textarea' ) ;
@@ -2149,6 +2189,16 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
21492189 { planBuiltLabel }
21502190 </ span >
21512191 </ div >
2192+ ) : approvedPlanIds . has ( tool . toolCallId ) ? (
2193+ < div className = "flex flex-wrap items-center gap-1.5 pt-0.5" data-testid = "proposed-plan-actions" >
2194+ < span
2195+ className = "inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
2196+ data-testid = "proposed-plan-building"
2197+ >
2198+ < Loader2 className = "size-3.5 animate-spin" />
2199+ { planBuildingLabel }
2200+ </ span >
2201+ </ div >
21522202 ) : (
21532203 < div
21542204 className = "flex flex-wrap items-center gap-1.5 pt-0.5"
@@ -2157,7 +2207,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
21572207 < button
21582208 type = "button"
21592209 onClick = { ( ) =>
2160- handlePlanApprove ( tool . proposedPlan ! . questions . length > 0 )
2210+ handlePlanApprove ( tool . proposedPlan ! . questions . length > 0 , tool . toolCallId )
21612211 }
21622212 className = "inline-flex h-7 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
21632213 data-testid = "proposed-plan-approve"
@@ -2211,6 +2261,16 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
22112261 { planBuiltLabel }
22122262 </ span >
22132263 </ div >
2264+ ) : approvedPlanIds . has ( tool . toolCallId ) ? (
2265+ < div className = "flex flex-wrap items-center gap-1.5 pt-0.5" data-testid = "proposed-plan-actions" >
2266+ < span
2267+ className = "inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
2268+ data-testid = "proposed-plan-building"
2269+ >
2270+ < Loader2 className = "size-3.5 animate-spin" />
2271+ { planBuildingLabel }
2272+ </ span >
2273+ </ div >
22142274 ) : (
22152275 < div
22162276 className = "flex flex-wrap items-center gap-1.5 pt-0.5"
@@ -2219,7 +2279,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
22192279 < button
22202280 type = "button"
22212281 // No structured questions to default through → plain approve.
2222- onClick = { ( ) => handlePlanApprove ( false ) }
2282+ onClick = { ( ) => handlePlanApprove ( false , tool . toolCallId ) }
22232283 className = "inline-flex h-7 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
22242284 data-testid = "proposed-plan-approve"
22252285 >
0 commit comments