@@ -92,6 +92,13 @@ const LOCAL_SESSION_RECOVERY_MESSAGE =
9292const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE =
9393 "Connecting to to the agent has been lost. Retry, or start a new session." ;
9494
95+ function isUserPromptEcho ( acpMsg : AcpMessage ) : boolean {
96+ return (
97+ isJsonRpcRequest ( acpMsg . message ) &&
98+ acpMsg . message . method === "session/prompt"
99+ ) ;
100+ }
101+
95102/**
96103 * Build default configOptions for cloud sessions so the mode switcher
97104 * is available in the UI even without a local agent connection.
@@ -1518,6 +1525,17 @@ export class SessionService {
15181525 isPromptPending : true ,
15191526 } ) ;
15201527
1528+ // Show the bubble immediately while we wait for the cloud log stream to
1529+ // echo the user_message back. Without this the user sees a gap between
1530+ // submit (or queue drain) and the agent's response.
1531+ if ( ! options ?. skipQueueGuard ) {
1532+ sessionStoreSetters . appendOptimisticItem ( session . taskRunId , {
1533+ type : "user_message" ,
1534+ content : transport . promptText ,
1535+ timestamp : Date . now ( ) ,
1536+ } ) ;
1537+ }
1538+
15211539 track ( ANALYTICS_EVENTS . PROMPT_SENT , {
15221540 task_id : session . taskId ,
15231541 is_initial : session . events . length === 0 ,
@@ -1565,6 +1583,12 @@ export class SessionService {
15651583 sessionStoreSetters . updateSession ( session . taskRunId , {
15661584 isPromptPending : false ,
15671585 } ) ;
1586+ // Drop optimistic items so a failed send doesn't leave a ghost bubble.
1587+ // The combined-prompt path (skipQueueGuard) clears its own optimistic
1588+ // items in sendQueuedCloudMessages on retry exhaustion.
1589+ if ( ! options ?. skipQueueGuard ) {
1590+ sessionStoreSetters . clearOptimisticItems ( session . taskRunId ) ;
1591+ }
15681592 throw error ;
15691593 }
15701594 }
@@ -1574,10 +1598,29 @@ export class SessionService {
15741598 attempt = 0 ,
15751599 pendingPrompt ?: string | ContentBlock [ ] ,
15761600 ) : Promise < { stopReason : string } > {
1577- // First attempt: atomically dequeue. Retries reuse the already-dequeued prompt.
1578- const combinedPrompt =
1579- pendingPrompt ??
1580- combineQueuedCloudPrompts ( sessionStoreSetters . dequeueMessages ( taskId ) ) ;
1601+ // First attempt: atomically dequeue and convert each entry into an
1602+ // optimistic bubble. Retries reuse the already-dequeued prompt and must
1603+ // not stack additional bubbles.
1604+ let combinedPrompt : string | ContentBlock [ ] | null ;
1605+ if ( pendingPrompt ) {
1606+ combinedPrompt = pendingPrompt ;
1607+ } else {
1608+ const dequeued = sessionStoreSetters . dequeueMessages ( taskId ) ;
1609+ combinedPrompt = combineQueuedCloudPrompts ( dequeued ) ;
1610+ if ( combinedPrompt ) {
1611+ const taskRunId =
1612+ sessionStoreSetters . getSessionByTaskId ( taskId ) ?. taskRunId ;
1613+ if ( taskRunId ) {
1614+ for ( const msg of dequeued ) {
1615+ sessionStoreSetters . appendOptimisticItem ( taskRunId , {
1616+ type : "user_message" ,
1617+ content : msg . content ,
1618+ timestamp : msg . queuedAt ,
1619+ } ) ;
1620+ }
1621+ }
1622+ }
1623+ }
15811624 if ( ! combinedPrompt ) return { stopReason : "skipped" } ;
15821625
15831626 const session = sessionStoreSetters . getSessionByTaskId ( taskId ) ;
@@ -1632,6 +1675,10 @@ export class SessionService {
16321675 taskId,
16331676 attempts : attempt + 1 ,
16341677 } ) ;
1678+ const failedSession = sessionStoreSetters . getSessionByTaskId ( taskId ) ;
1679+ if ( failedSession ) {
1680+ sessionStoreSetters . clearOptimisticItems ( failedSession . taskRunId ) ;
1681+ }
16351682 toast . error ( "Failed to send follow-up message. Please try again." ) ;
16361683 return { stopReason : "error" } ;
16371684 }
@@ -2901,6 +2948,9 @@ export class SessionService {
29012948 ) ;
29022949 sessionStoreSetters . appendEvents ( taskRunId , newEvents , expectedCount ) ;
29032950 this . updatePromptStateFromEvents ( taskRunId , newEvents ) ;
2951+ if ( newEvents . some ( isUserPromptEcho ) ) {
2952+ sessionStoreSetters . clearOptimisticItems ( taskRunId ) ;
2953+ }
29042954 } else {
29052955 // Gap in data — append everything we have but don't jump processedLineCount
29062956 log . warn ( "Cloud task log count inconsistency" , {
@@ -2921,6 +2971,9 @@ export class SessionService {
29212971 currentCount + update . newEntries . length ,
29222972 ) ;
29232973 this . updatePromptStateFromEvents ( taskRunId , newEvents ) ;
2974+ if ( newEvents . some ( isUserPromptEcho ) ) {
2975+ sessionStoreSetters . clearOptimisticItems ( taskRunId ) ;
2976+ }
29242977 }
29252978 }
29262979
@@ -2983,6 +3036,7 @@ export class SessionService {
29833036 if ( session && session . messageQueue . length > 0 ) {
29843037 const queued = sessionStoreSetters . dequeueMessages ( session . taskId ) ;
29853038 const combinedPrompt = combineQueuedCloudPrompts ( queued ) ;
3039+ sessionStoreSetters . clearOptimisticItems ( taskRunId ) ;
29863040 sessionStoreSetters . updateSession ( taskRunId , {
29873041 isPromptPending : false ,
29883042 } ) ;
@@ -2998,6 +3052,7 @@ export class SessionService {
29983052 } ) ;
29993053 }
30003054 } else if ( session ?. isPromptPending ) {
3055+ sessionStoreSetters . clearOptimisticItems ( taskRunId ) ;
30013056 sessionStoreSetters . updateSession ( taskRunId , {
30023057 isPromptPending : false ,
30033058 } ) ;
0 commit comments