@@ -290,6 +290,7 @@ import {
290290 type CompactStrategyChoice ,
291291 MAX_PUSH_DEPTH ,
292292 setPushStackMirror ,
293+ projectPushStackOntoMessages ,
293294} from '../services/pushStack/state.js' ;
294295import { DIGEST_PROMPT , DIGEST_TEMPLATE } from '../services/pushStack/digestPrompt.js' ;
295296import { CompactStrategyDialog } from '../components/pushStack/CompactStrategyDialog.js' ;
@@ -317,6 +318,7 @@ import {
317318 isEphemeralToolProgress ,
318319 isLoggableMessage ,
319320 saveWorktreeState ,
321+ savePushStack ,
320322 getAgentTranscript ,
321323} from '../utils/sessionStorage.js' ;
322324import { deserializeMessages } from '../utils/conversationRecovery.js' ;
@@ -1770,6 +1772,10 @@ export function REPL({
17701772 // streaming text_delta. The spinner reads this via its animation timer.
17711773 const responseLengthRef = useRef ( 0 ) ;
17721774 const compactProgressActiveRef = useRef ( false ) ;
1775+ // Pop-specific spinner label (e.g. "Distilling discussion branch #1"). Set by
1776+ // applyPop before its fire-and-forget digest so the compact_start handler can
1777+ // show a pop-aware verb instead of the generic "Compacting conversation".
1778+ const popSpinnerLabelRef = useRef < string | null > ( null ) ;
17731779 // API performance metrics ref for ant-only spinner display (TTFT/OTPS).
17741780 // Accumulates metrics from all API requests in a turn for P50 aggregation.
17751781 const apiMetricsRef = useRef <
@@ -1825,6 +1831,11 @@ export function REPL({
18251831
18261832 const [ lastQueryCompletionTime , setLastQueryCompletionTime ] = useState ( 0 ) ;
18271833 const [ spinnerMessage , setSpinnerMessage ] = useState < string | null > ( null ) ;
1834+ // State mirror of compactProgressActiveRef — needed so showSpinner (a
1835+ // render-time derived value) turns the spinner on during a background compact
1836+ // that runs outside the query loop (e.g. /pop's fire-and-forget digest, where
1837+ // isLoading is already false). The ref alone can't trigger a re-render.
1838+ const [ compactProgressActive , setCompactProgressActive ] = useState ( false ) ;
18281839 const [ spinnerColor , setSpinnerColor ] = useState < keyof Theme | null > ( null ) ;
18291840 const [ spinnerShimmerColor , setSpinnerShimmerColor ] = useState < keyof Theme | null > ( null ) ;
18301841 const [ isMessageSelectorVisible , setIsMessageSelectorVisible ] = useState ( false ) ;
@@ -2061,6 +2072,8 @@ export function REPL({
20612072 ( isLoading ||
20622073 userInputOnProcessing ||
20632074 hasRunningTeammates ||
2075+ // Background compaction running outside the query loop (e.g. /pop digest).
2076+ compactProgressActive ||
20642077 // Keep spinner visible while task notifications are queued for processing.
20652078 // Without this, the spinner briefly disappears between consecutive notifications
20662079 // (e.g., multiple background agents completing in rapid succession) because
@@ -2344,6 +2357,28 @@ export function REPL({
23442357 // Use a callback to ensure we're not dependent on stale state
23452358 setMessages ( ( ) => messages ) ;
23462359
2360+ // Hydrate the push/pop stack, re-projecting each marker onto the just-
2361+ // restored messages: a marker only survives if its anchor is still in
2362+ // the active context (not carried off by compaction/snip), the same
2363+ // check /pop performs — so a restored stack can never point at a message
2364+ // that no longer exists. Dropped markers are surfaced so a persisted
2365+ // stack is never silently lost. The setAppState below re-triggers the
2366+ // mirror effect, re-persisting the projected (pruned) stack last-wins.
2367+ // Skipped for /branch (fork) like worktreeSession: forkLog doesn't carry
2368+ // the stack.
2369+ if ( feature ( 'PUSH_POP' ) && entrypoint !== 'fork' && log . pushStack ?. length ) {
2370+ const { validMarkers, droppedCount } = projectPushStackOntoMessages ( log . pushStack , messages ) ;
2371+ setAppState ( prev => ( { ...prev , pushStack : validMarkers } ) ) ;
2372+ if ( droppedCount > 0 ) {
2373+ addNotification ( {
2374+ key : 'push-stack-resume-dropped' ,
2375+ text : `${ droppedCount } 个 push 点在恢复后已失效(被压缩/snip 卷走),已移除` ,
2376+ priority : 'medium' ,
2377+ timeoutMs : 6000 ,
2378+ } ) ;
2379+ }
2380+ }
2381+
23472382 // Clear any active tool JSX
23482383 setToolJSX ( null ) ;
23492384
@@ -2363,7 +2398,7 @@ export function REPL({
23632398 throw error ;
23642399 }
23652400 } ,
2366- [ resetLoadingState , setAppState ] ,
2401+ [ resetLoadingState , setAppState , addNotification ] ,
23672402 ) ;
23682403
23692404 // Lazy init: useRef(createX()) would call createX on every render and
@@ -2918,9 +2953,17 @@ export function REPL({
29182953 ) ;
29192954
29202955 // Keep the module-level mirror in sync so autoCompactIfNeeded (outside React)
2921- // can read the current push stack without needing AppState access.
2956+ // can read the current push stack without needing AppState access. This is
2957+ // also the single point that fans in every stack mutation (push/pop/retain),
2958+ // so it doubles as the persistence trigger: once the stack has been non-empty,
2959+ // each change (including popping back to empty) is written last-wins so the
2960+ // transcript always reflects the live stack. The dirty ref keeps sessions that
2961+ // never push from writing an empty entry.
2962+ const pushStackDirty = useRef ( false ) ;
29222963 useEffect ( ( ) => {
29232964 setPushStackMirror ( pushStack ) ;
2965+ if ( pushStack . length > 0 ) pushStackDirty . current = true ;
2966+ if ( pushStackDirty . current ) savePushStack ( pushStack ) ;
29242967 } , [ pushStack ] ) ;
29252968
29262969 // Ref holding the stable push/pop callbacks so getToolUseContext can forward-
@@ -3071,14 +3114,24 @@ export function REPL({
30713114 ) ;
30723115 break ;
30733116 case 'compact_start' :
3074- setSpinnerMessage ( 'Compacting conversation' ) ;
3117+ setSpinnerMessage ( popSpinnerLabelRef . current ?? 'Compacting conversation' ) ;
30753118 compactProgressActiveRef . current = true ;
3119+ setCompactProgressActive ( true ) ;
3120+ // /pop runs its digest outside the query loop, so the normal
3121+ // loading timer never started — seed it here (when idle) so the
3122+ // spinner's elapsed-time counter starts from 0 rather than a
3123+ // stale value from a previous turn.
3124+ if ( ! isLoading ) {
3125+ loadingStartTimeRef . current = Date . now ( ) ;
3126+ totalPausedMsRef . current = 0 ;
3127+ }
30763128 break ;
30773129 case 'compact_end' :
30783130 setSpinnerMessage ( null ) ;
30793131 setSpinnerColor ( null ) ;
30803132 setSpinnerShimmerColor ( null ) ;
30813133 compactProgressActiveRef . current = false ;
3134+ setCompactProgressActive ( false ) ;
30823135 break ;
30833136 }
30843137 } ,
@@ -3247,13 +3300,17 @@ export function REPL({
32473300 ] ) ;
32483301 return ;
32493302 }
3250- const lastContent =
3251- ( last as { content ?: unknown ; message ?: { content ?: unknown } } ) . content ??
3252- ( last as { content ?: unknown ; message ?: { content ?: unknown } } ) . message ?. content ??
3253- '' ;
3254- const anchorText =
3255- getContentText ( lastContent as string | import ( '@anthropic-ai/sdk/resources/index.mjs' ) . ContentBlockParam [ ] ) ??
3303+ // Preview the user's most recent instruction (what this branch is about)
3304+ // rather than the raw last message — which is often a tool-only assistant
3305+ // turn (empty text) or a synthetic "No response requested." placeholder,
3306+ // neither of which is meaningful in `/push --list` (§4.7).
3307+ const instruction = msgs . findLast ( selectableUserMessagesFilter ) ;
3308+ const previewSource = instruction ?? last ;
3309+ const previewContent =
3310+ ( previewSource as { content ?: unknown ; message ?: { content ?: unknown } } ) . content ??
3311+ ( previewSource as { content ?: unknown ; message ?: { content ?: unknown } } ) . message ?. content ??
32563312 '' ;
3313+ const anchorText = getContentText ( previewContent as string | ContentBlockParam [ ] ) ?? '' ;
32573314 const anchorPreview = anchorText . slice ( 0 , 75 ) . trimEnd ( ) ;
32583315 const marker : PushMarker = {
32593316 id : randomUUID ( ) ,
@@ -3354,8 +3411,13 @@ export function REPL({
33543411 // the last main-line message that must stay in the kept prefix (§3
33553412 // off-by-one). The fast-path above guarantees pivotIndex is in range.
33563413 const firstDiscussionUuid = compactMessages [ pivotIndex ] ! . uuid ;
3414+ let compactResult : import ( '../services/compact/compact.js' ) . CompactionResult | undefined ;
3415+ // Pop-aware spinner label — the compact_start handler reads this so the
3416+ // digest shows "Distilling discussion branch #N" (with live token count
3417+ // + elapsed time) instead of the generic "Compacting conversation".
3418+ popSpinnerLabelRef . current = `\u2442 Distilling discussion branch #${ targetIdx + 1 } ` ;
33573419 try {
3358- await applyPartialCompactByUuid ( {
3420+ compactResult = await applyPartialCompactByUuid ( {
33593421 pivotUuid : firstDiscussionUuid ,
33603422 feedback : DIGEST_TEMPLATE ,
33613423 direction : 'from' ,
@@ -3371,6 +3433,8 @@ export function REPL({
33713433 ) ,
33723434 ] ) ;
33733435 return ;
3436+ } finally {
3437+ popSpinnerLabelRef . current = null ;
33743438 }
33753439
33763440 // Pop the stack: plain pop removes top; --to #N removes #N and above.
@@ -3391,9 +3455,17 @@ export function REPL({
33913455 }
33923456
33933457 const historyShortcut = getShortcutDisplay ( 'app:toggleTranscript' , 'Global' , 'ctrl+o' ) ;
3458+ // Before → after context size (0 extra API tokens: pre is already
3459+ // computed during compaction, post is a local message-payload estimate).
3460+ const pre = compactResult ?. preCompactTokenCount ;
3461+ const post = compactResult ?. truePostCompactTokenCount ;
3462+ const tokenLine =
3463+ pre !== undefined && post !== undefined
3464+ ? ` · Context: ~${ formatTokens ( pre ) } \u2192 ~${ formatTokens ( post ) } tokens`
3465+ : '' ;
33943466 addNotification ( {
33953467 key : `pop-digest-${ marker . id } ` ,
3396- text : `\u2442 Discussion branch #${ targetIdx + 1 } distilled (${ historyShortcut } for history)` ,
3468+ text : `\u2442 Discussion branch #${ targetIdx + 1 } distilled (${ historyShortcut } for history)${ tokenLine } ` ,
33973469 priority : 'medium' ,
33983470 timeoutMs : 8000 ,
33993471 } ) ;
0 commit comments