@@ -17,6 +17,10 @@ import { TranscriptView } from './transcript/view.js';
1717import { TuiChatController , type TuiChatSnapshot } from './controller/chat-controller.js' ;
1818import { TuiChromeFlow } from './controller/product/chrome-flow.js' ;
1919import type { TuiDraftLifecycle } from './features/composer/draft-lifecycle.js' ;
20+ import { retainAvailableAttachmentPlaceholders } from './features/composer/draft-recovery.js' ;
21+ import type { TuiSubmissionSnapshot } from './features/composer/submission.js' ;
22+ import { statSync } from 'node:fs' ;
23+ import type { TuiTransportAttachment } from '../types/invocation.js' ;
2024import { TuiRunProjection } from './state/run-projection.js' ;
2125import { createTuiState , TuiEffectRunner , TuiStateStore } from './state/index.js' ;
2226import { FeedbackFlow as Feedback } from './controller/product/feedback-flow.js' ;
@@ -358,6 +362,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
358362 keybindings : options . keybindings ,
359363 isStopped : ( ) => stopped ,
360364 } ) ;
365+ // Turns whose prompt was already returned to the composer by the abort
366+ // restore; keys are runtime turn ids, unique for the app's lifetime.
367+ const restoredAbortTurnIds = new Set < string > ( ) ;
361368 abortLiveTurn = createTuiAbortLiveTurn ( {
362369 controller,
363370 runProjection,
@@ -371,6 +378,125 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
371378 if ( ! stopped ) tui . requestRender ( ) ;
372379 } ,
373380 append : appendLocalCell ,
381+ onLiveTurnAborted : ( { turnId } ) => {
382+ // Return the aborted prompt to the composer only when the turn produced
383+ // no user-visible output: any assistant text, tool call, or steer
384+ // message means the user was interacting with a live response, not
385+ // regretting a fresh submission. Thinking is internal model process and
386+ // does not disqualify: it is the most common regret moment, and nothing
387+ // of value is discarded by resending.
388+ if ( sessionFlow . isSideModeActive ( ) ) return ;
389+ const cells = transcript . snapshot ( ) . filter ( ( cell ) => cell . turnId === turnId ) ;
390+ const userCell = transcript . get ( `user:${ turnId } ` ) ;
391+ if ( ! userCell ) return ; // runtime-owned or retry-continuation turns
392+ // A second Esc in the settle window can reach the runtime-owned branch
393+ // with the same turnId; track restored turns so a repeat concatenates
394+ // nothing. (Cell status cannot key this: abort-time markTurn also
395+ // cancels still-pending user cells before the first restore.)
396+ if ( restoredAbortTurnIds . has ( turnId ) ) return ;
397+ const hasIrreversibleActivity = cells . some ( ( cell ) => {
398+ if ( cell . id === `user:${ turnId } ` ) return false ;
399+ if ( cell . kind === 'thinking' || cell . kind === 'turn-duration' ) return false ;
400+ // markTurn('cancelled') synthesizes an empty assistant placeholder for
401+ // turns with no assistant output before this callback can run.
402+ if ( ( cell . kind === 'assistant' || cell . kind === 'assistant-preamble' ) && ! cell . content ) {
403+ return false ;
404+ }
405+ return true ;
406+ } ) ;
407+ if ( hasIrreversibleActivity ) return ;
408+ // Preferred path: replay the ORIGINAL submission retained for this turn
409+ // (complete attachment set, transport content, client intent, editor
410+ // state) instead of reconstructing a lossy copy from the display cell.
411+ const retained = commandFlow . getRetainedSubmission ( turnId ) ;
412+ const retainedFiltered = retained
413+ ? filterRetainedSubmissionForRestore ( retained )
414+ : undefined ;
415+ if ( retained && retainedFiltered ) {
416+ commandFlow . restoreSubmission ( retainedFiltered ) ;
417+ commandFlow . markAbortSubmissionRestored ( retained . submissionId ) ;
418+ commandFlow . dropRetainedSubmission ( turnId ) ;
419+ restoredAbortTurnIds . add ( turnId ) ;
420+ markAbortedUserRowCancelled ( transcript , turnId ) ;
421+ draftLifecycle ?. recordRestoredSubmission ( retainedFiltered ) ;
422+ chromeFlow ?. setHint ( 'Stopped · message restored to the Composer.' ) ;
423+ updateChrome ( controller . snapshot ( ) ) ;
424+ tui . requestRender ( ) ;
425+ return ;
426+ }
427+ const text = userCell . content ;
428+ const draftAttachments = ( userCell . attachments ?? [ ] ) . flatMap ( ( attachment ) =>
429+ attachment . filePath && typeof attachment . sizeBytes === 'number'
430+ ? [
431+ {
432+ type : attachment . type ,
433+ fileName : attachment . fileName ,
434+ mimeType : attachment . mimeType ,
435+ sizeBytes : attachment . sizeBytes ,
436+ filePath : attachment . filePath ,
437+ } ,
438+ ]
439+ : [ ] ,
440+ ) ;
441+ // Fallback path: reconstruct from the display cell. The transport list
442+ // must carry BOTH halves of a mixed row (asset-backed AND file-backed
443+ // entries): submission-time selection is `transportAttachments ??
444+ // attachments`, so a partial transport list would silently drop the
445+ // file-backed attachments on resubmit.
446+ const transportAttachments : TuiTransportAttachment [ ] = ( userCell . attachments ?? [ ] ) . flatMap (
447+ ( attachment ) : TuiTransportAttachment [ ] =>
448+ attachment . assetId
449+ ? [
450+ {
451+ type : attachment . type ,
452+ fileName : attachment . fileName ,
453+ mimeType : attachment . mimeType ,
454+ ...( attachment . filePath ? { filePath : attachment . filePath } : { } ) ,
455+ assetId : attachment . assetId ,
456+ } ,
457+ ]
458+ : attachment . filePath && typeof attachment . sizeBytes === 'number'
459+ ? [
460+ {
461+ type : attachment . type ,
462+ fileName : attachment . fileName ,
463+ mimeType : attachment . mimeType ,
464+ filePath : attachment . filePath ,
465+ } ,
466+ ]
467+ : [ ] ,
468+ ) ;
469+ if ( ! text . trim ( ) && draftAttachments . length === 0 && transportAttachments . length === 0 ) {
470+ return ;
471+ }
472+ const restoredSubmission : TuiSubmissionSnapshot = {
473+ submissionId : `abort-restore:${ turnId } ` ,
474+ sessionId : controller . snapshot ( ) . session ?. sessionId ,
475+ content : text ,
476+ attachments : draftAttachments ,
477+ ...( transportAttachments . length > 0 ? { transportAttachments } : { } ) ,
478+ createdAtMs : Date . now ( ) ,
479+ editor : {
480+ schemaVersion : 1 ,
481+ text,
482+ cursor : text . length ,
483+ pastes : [ ] ,
484+ pasteCounter : 0 ,
485+ } ,
486+ } ;
487+ commandFlow . restoreSubmission ( restoredSubmission ) ;
488+ restoredAbortTurnIds . add ( turnId ) ;
489+ markAbortedUserRowCancelled ( transcript , turnId ) ;
490+ // The restored text already carries the submission's content; a pending
491+ // retry for it would merge the same text again on the next hydrate.
492+ draftLifecycle ?. recordRestoredSubmission ( restoredSubmission ) ;
493+ chromeFlow ?. setHint ( 'Stopped · message restored to the Composer.' ) ;
494+ // The funnel's updateChrome tail ran during the settle wait, before this
495+ // hint was set; push the chrome once more so the hint is not left
496+ // unpresented.
497+ updateChrome ( controller . snapshot ( ) ) ;
498+ tui . requestRender ( ) ;
499+ } ,
374500 } ) ;
375501 const planModeFlow = new TuiPlanModeFlow ( {
376502 currentSession : ( ) => controller . snapshot ( ) . session ,
@@ -447,8 +573,11 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
447573 } ,
448574 followBottom : ( ) => layout . forceFollowBottom ( ) ,
449575 requestWelcomeRebuild : ( ) => tui . requestImmediateRender ( ) ,
450- switchComposerDraft : ( sessionKey ) =>
451- draftLifecycle ?. switchSession ( sessionKey ) ?? Promise . resolve ( ) ,
576+ switchComposerDraft : ( sessionKey ) => {
577+ // Retained turn submissions belong to the previous session's turns.
578+ commandFlow . clearRetainedSubmissions ( ) ;
579+ return draftLifecycle ?. switchSession ( sessionKey ) ?? Promise . resolve ( ) ;
580+ } ,
452581 detachForegroundObserver : ( ) => runtimeEventFlow ?. detachForegroundObserver ( ) ,
453582 adoptForegroundRun : ( ) =>
454583 sessionLifecycle . adoptForegroundRun (
@@ -730,8 +859,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
730859 activity . dispose ( ) ;
731860 widgets . imagePreview . dispose ( ) ;
732861 renderer . prepareTranscriptExit ( ) ;
733- disposeComponents ( surfaceHost , editor , transcriptView , status , goal ) ;
734862 draftStopPromise ??= draftLifecycle ?. stop ( ) ?? Promise . resolve ( ) ;
863+ disposeComponents ( surfaceHost , editor , transcriptView , status , goal ) ;
735864 if ( stopOptions . abortActiveTurn !== false ) {
736865 const snapshot = controller . snapshot ( ) ;
737866 if ( snapshot . activeTurnId ) void controller . abort ( ) . catch ( ( ) => undefined ) ;
@@ -808,8 +937,70 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
808937 updateChromeAndRequestRender ( ) ;
809938 } ,
810939 submit : ( input ) => commandFlow . submit ( input ) . then ( ( ) => undefined ) ,
940+ /** Exposed for integration tests that submit with an explicit seed. */
941+ commandFlow,
811942 abortTurn : abortLiveTurn ,
812943 leaveUi,
813944 stop,
814945 } ;
815946}
947+
948+ function fileExists ( filePath : string ) : boolean {
949+ try {
950+ return statSync ( filePath ) . isFile ( ) ;
951+ } catch {
952+ return false ;
953+ }
954+ }
955+
956+ /**
957+ * Filters a retained submission snapshot for abort-time restore: drops
958+ * attachments whose backing file disappeared (asset-backed entries stay),
959+ * and prunes editor placeholders accordingly. Returns undefined when nothing
960+ * restorable remains.
961+ */
962+ function filterRetainedSubmissionForRestore (
963+ snapshot : TuiSubmissionSnapshot ,
964+ ) : TuiSubmissionSnapshot | undefined {
965+ const attachments = snapshot . attachments . filter ( ( attachment ) =>
966+ fileExists ( attachment . filePath ) ,
967+ ) ;
968+ const transportAttachments = ( snapshot . transportAttachments ?? [ ] ) . filter ( ( attachment ) =>
969+ attachment . assetId ? true : attachment . filePath ? fileExists ( attachment . filePath ) : false ,
970+ ) ;
971+ if ( ! snapshot . content . trim ( ) && attachments . length === 0 && transportAttachments . length === 0 ) {
972+ return undefined ;
973+ }
974+ return {
975+ ...snapshot ,
976+ submissionId : `abort-restore:${ snapshot . submissionId } ` ,
977+ attachments,
978+ ...( snapshot . transportAttachments ? { transportAttachments } : { } ) ,
979+ editor : retainAvailableAttachmentPlaceholders (
980+ // Some submission paths (automation results, test seeds) submit with a
981+ // payload but an empty editor draft; restoring that verbatim would
982+ // leave the composer blank, so fall back to the visible content.
983+ snapshot . editor . text . trim ( )
984+ ? snapshot . editor
985+ : { ...snapshot . editor , text : snapshot . content , cursor : snapshot . content . length } ,
986+ attachments ,
987+ ) ,
988+ } ;
989+ }
990+
991+ /** Marks the aborted turn's user row cancelled so it stays visible in history. */
992+ function markAbortedUserRowCancelled ( transcript : TranscriptStore , turnId : string ) : void {
993+ const userCell = transcript . get ( `user:${ turnId } ` ) ;
994+ if ( ! userCell ) return ;
995+ // markTurn does not rewrite the user cell once the runtime echo flipped it
996+ // to 'succeeded', so upsert explicitly. createdAtMs is force-preserved by
997+ // the store's merge for existing cells and is intentionally omitted.
998+ transcript . upsert ( {
999+ id : `user:${ turnId } ` ,
1000+ kind : 'user' ,
1001+ status : 'cancelled' ,
1002+ ...( userCell . content ? { content : userCell . content } : { } ) ,
1003+ updatedAtMs : Date . now ( ) ,
1004+ ...( userCell . attachments ? { attachments : userCell . attachments } : { } ) ,
1005+ } ) ;
1006+ }
0 commit comments