11import { useEffect , useRef } from "react" ;
22import { useWorkspaceStoreRaw , type WorkspaceState } from "@/browser/stores/WorkspaceStore" ;
33import { CUSTOM_EVENTS , type CustomEventType } from "@/common/constants/events" ;
4- import { getAutoRetryKey , getRetryStateKey } from "@/common/constants/storage" ;
4+ import {
5+ getAutoRetryKey ,
6+ getRetryStateKey ,
7+ getCancelledCompactionKey ,
8+ } from "@/common/constants/storage" ;
59import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions" ;
610import { readPersistedState , updatePersistedState } from "./usePersistedState" ;
711import {
812 isEligibleForAutoRetry ,
913 isNonRetryableSendError ,
1014} from "@/browser/utils/messages/retryEligibility" ;
11- import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions " ;
15+ import { executeCompaction } from "@/browser/utils/chatCommands " ;
1216import type { SendMessageError } from "@/common/types/errors" ;
1317import {
1418 createFailedRetryState ,
@@ -23,6 +27,15 @@ export interface RetryState {
2327 lastError ?: SendMessageError ;
2428}
2529
30+ /**
31+ * Persisted marker for user-cancelled compaction.
32+ * Used to distinguish intentional cancellation (Ctrl+C) from crash/force-exit.
33+ */
34+ export interface CancelledCompactionMarker {
35+ messageId : string ;
36+ timestamp : number ;
37+ }
38+
2639/**
2740 * Centralized auto-resume manager for interrupted streams
2841 *
@@ -163,45 +176,80 @@ export function useResumeManager() {
163176 ) ;
164177
165178 try {
179+ if ( ! api ) {
180+ retryingRef . current . delete ( workspaceId ) ;
181+ return ;
182+ }
183+
166184 // Start with workspace defaults
167- let options = getSendOptionsFromStorage ( workspaceId ) ;
185+ const options = getSendOptionsFromStorage ( workspaceId ) ;
168186
169187 // Check if last user message was a compaction request
170188 const state = workspaceStatesRef . current . get ( workspaceId ) ;
171- if ( state ) {
172- const lastUserMsg = [ ...state . messages ] . reverse ( ) . find ( ( msg ) => msg . type === "user" ) ;
173- if ( lastUserMsg ?. compactionRequest ) {
174- // Apply compaction overrides using shared function (same as ChatInput)
175- // This ensures custom model/tokens are preserved across resume
176- options = applyCompactionOverrides ( options , {
177- model : lastUserMsg . compactionRequest . parsed . model ,
178- maxOutputTokens : lastUserMsg . compactionRequest . parsed . maxOutputTokens ,
179- continueMessage : {
180- text : lastUserMsg . compactionRequest . parsed . continueMessage ?. text ?? "" ,
181- imageParts : lastUserMsg . compactionRequest . parsed . continueMessage ?. imageParts ,
182- model : lastUserMsg . compactionRequest . parsed . continueMessage ?. model ?? options . model ,
183- mode : lastUserMsg . compactionRequest . parsed . continueMessage ?. mode ?? "exec" ,
184- } ,
185- } ) ;
189+ const lastUserMsg = state ?. messages
190+ ? [ ...state . messages ] . reverse ( ) . find ( ( msg ) => msg . type === "user" )
191+ : undefined ;
192+
193+ if ( lastUserMsg ?. compactionRequest ) {
194+ // Check if this compaction was user-cancelled (Ctrl+C)
195+ const cancelledMarker = readPersistedState < CancelledCompactionMarker | null > (
196+ getCancelledCompactionKey ( workspaceId ) ,
197+ null
198+ ) ;
199+
200+ if ( cancelledMarker && cancelledMarker . messageId === lastUserMsg . id ) {
201+ if ( ! isManual ) {
202+ // User explicitly cancelled this compaction - don't auto-retry
203+ console . debug (
204+ `[retry] ${ workspaceId } skipping cancelled compaction (messageId=${ lastUserMsg . id } )`
205+ ) ;
206+ return ;
207+ }
208+
209+ // Manual retry: clear the marker and proceed
210+ updatePersistedState ( getCancelledCompactionKey ( workspaceId ) , ( ) => null ) ;
186211 }
187- }
188212
189- if ( ! api ) {
190- retryingRef . current . delete ( workspaceId ) ;
191- return ;
192- }
193- const result = await api . workspace . resumeStream ( { workspaceId, options } ) ;
213+ // Retry compaction via executeCompaction (re-sends the compaction request)
214+ // This properly rebuilds the compaction-specific behavior including continueMessage queuing
215+ console . debug ( `[retry] ${ workspaceId } retrying interrupted compaction` ) ;
216+ const { parsed } = lastUserMsg . compactionRequest ;
217+ const result = await executeCompaction ( {
218+ api,
219+ workspaceId,
220+ sendMessageOptions : options ,
221+ model : parsed . model ,
222+ maxOutputTokens : parsed . maxOutputTokens ,
223+ continueMessage : parsed . continueMessage ,
224+ editMessageId : lastUserMsg . id , // Edit the existing compaction request message
225+ } ) ;
194226
195- if ( ! result . success ) {
196- // Store error in retry state so RetryBarrier can display it
197- const newState = createFailedRetryState ( attempt , result . error ) ;
198- console . debug (
199- `[retry] ${ workspaceId } resumeStream failed: attempt ${ attempt } → ${ newState . attempt } `
200- ) ;
201- updatePersistedState ( getRetryStateKey ( workspaceId ) , newState ) ;
227+ if ( ! result . success ) {
228+ const errorData : SendMessageError = {
229+ type : "unknown" ,
230+ raw : result . error ?? "Failed to retry compaction" ,
231+ } ;
232+ const newState = createFailedRetryState ( attempt , errorData ) ;
233+ console . debug (
234+ `[retry] ${ workspaceId } compaction failed: attempt ${ attempt } → ${ newState . attempt } `
235+ ) ;
236+ updatePersistedState ( getRetryStateKey ( workspaceId ) , newState ) ;
237+ }
238+ } else {
239+ // Normal stream resume (non-compaction)
240+ const result = await api . workspace . resumeStream ( { workspaceId, options } ) ;
241+
242+ if ( ! result . success ) {
243+ // Store error in retry state so RetryBarrier can display it
244+ const newState = createFailedRetryState ( attempt , result . error ) ;
245+ console . debug (
246+ `[retry] ${ workspaceId } resumeStream failed: attempt ${ attempt } → ${ newState . attempt } `
247+ ) ;
248+ updatePersistedState ( getRetryStateKey ( workspaceId ) , newState ) ;
249+ }
202250 }
203251 // Note: Don't clear retry state on success - stream-end event will handle that
204- // resumeStream success just means "stream initiated", not "stream completed"
252+ // resumeStream/executeCompaction success just means "stream initiated", not "stream completed"
205253 // Clearing here causes backoff reset bug when stream starts then immediately fails
206254 } catch ( error ) {
207255 // Store error in retry state for display
0 commit comments