From 17eac385c8f0ecd8528ca7c13411ed2f9d7d5ce0 Mon Sep 17 00:00:00 2001 From: moyu Date: Fri, 12 Jun 2026 16:21:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E6=96=AD=E5=BC=80=E6=83=85=E5=86=B5=EF=BC=8C?= =?UTF-8?q?resume=E7=BB=AD=E8=B7=91=EF=BC=9B=E4=BF=AE=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF=E6=8E=92=E9=98=9F=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E8=A2=ABgoal=E8=BE=93=E5=87=BA=E4=BF=A1=E6=81=AF=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/goal/goal.tsx | 10 +++++ src/hooks/useGoalContinuation.ts | 41 +++++++++++-------- src/screens/REPL.tsx | 6 ++- src/screens/ResumeConversation.tsx | 4 +- src/types/command.ts | 2 + src/utils/conversationRecovery.ts | 4 ++ .../processUserInput/processSlashCommand.tsx | 7 +++- src/utils/sessionRestore.ts | 11 +++++ src/utils/sessionStorage.ts | 10 ++++- 9 files changed, 71 insertions(+), 24 deletions(-) diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx index bc3fdb819..2c2391aad 100644 --- a/src/commands/goal/goal.tsx +++ b/src/commands/goal/goal.tsx @@ -36,6 +36,13 @@ import { removeByFilter } from 'src/utils/messageQueueManager.js'; import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js'; const MAX_OBJECTIVE_CHARS = 4000; +const MAX_DISPLAY_CHARS = 80; + +function truncateForDisplay(objective: string): string { + const firstLine = objective.split('\n')[0] ?? objective; + if (firstLine.length <= MAX_DISPLAY_CHARS) return firstLine; + return firstLine.slice(0, MAX_DISPLAY_CHARS) + '…'; +} function drainGoalContinuationQueue(): void { removeByFilter( @@ -174,6 +181,7 @@ export async function call( onDone(summary, { display: 'system', shouldQuery: true, + displayArgs: truncateForDisplay(trimmed), metaMessages: [`\n${trimmed}\n`], }); return null; @@ -184,10 +192,12 @@ export async function call( currentGoal={existing} newObjective={trimmed} onConfirm={() => { + drainGoalContinuationQueue(); const summary = applySetGoal(trimmed); onDone(summary, { display: 'system', shouldQuery: true, + displayArgs: truncateForDisplay(trimmed), metaMessages: [`\n${trimmed}\n`], }); }} diff --git a/src/hooks/useGoalContinuation.ts b/src/hooks/useGoalContinuation.ts index 10a5b03b4..6f3d1fc8d 100644 --- a/src/hooks/useGoalContinuation.ts +++ b/src/hooks/useGoalContinuation.ts @@ -9,21 +9,22 @@ * 1. GOAL feature flag enabled * 2. Goal exists and status === 'active' * 3. Query just finished (isLoading transitioned false) - * 4. No pending user input in queue - * 5. No active local-JSX UI (modal dialog) - * 6. Not in plan mode - * 7. turnsExecuted < MAX_GOAL_TURNS + * 4. No active local-JSX UI (modal dialog) + * 5. Not in plan mode + * 6. turnsExecuted < MAX_GOAL_TURNS + * 7. No user messages in the queue (user input always takes priority) * - * When all conditions are met, a meta-message containing the - * continuation prompt is enqueued. The queue processor picks it up - * and submits it as a new turn — the model sees the steering prompt - * and continues working towards the goal. + * When user messages are queued during a goal turn, the hook always + * yields to let them process first. After the user messages are + * handled, the next idle will fire the hook again to continue. + * This ensures commands like `/goal pause` are never starved by + * auto-continuation. * * The hook is intentionally simple: a single useEffect that fires * when `isLoading` flips to false. No timers, no intervals — the * idle→enqueue→process→query→idle cycle is self-sustaining. */ -import { useEffect, useRef } from 'react' +import { useLayoutEffect, useRef } from 'react' import { logForDebugging } from 'src/utils/debug.js' import { @@ -37,7 +38,10 @@ import { buildBudgetLimitPrompt, buildContinuationPrompt, } from 'src/services/goal/prompts.js' -import { enqueue, getCommandQueueSnapshot } from 'src/utils/messageQueueManager.js' +import { + enqueue, + getCommandQueueSnapshot, +} from 'src/utils/messageQueueManager.js' function hookLog(msg: string): void { logForDebugging(`[goal] hook: ${msg}`) @@ -63,10 +67,13 @@ export function useGoalContinuation( const optsRef = useRef(opts) optsRef.current = opts + // Track whether we already enqueued for the current idle window. + // Reset to false every time isLoading becomes true (new turn starts). const enqueuedRef = useRef(false) + // Fire budget_limit prompt exactly once per budget transition. const budgetLimitFiredRef = useRef(false) - useEffect(() => { + useLayoutEffect(() => { if (opts.isLoading) { enqueuedRef.current = false return @@ -86,13 +93,15 @@ export function useGoalContinuation( // Already enqueued for this idle window if (enqueuedRef.current) return - // Blocking conditions + // User messages always take priority over auto-continuation. + // If the user typed something (e.g. `/goal pause`) while a turn was + // running, let their message process first. After it finishes, the + // next idle cycle will re-evaluate whether to continue. const liveQueueLength = getCommandQueueSnapshot().length if (liveQueueLength > 0) { - hookLog('skip: liveQueuedCommands=' + liveQueueLength) + hookLog('skip: yielding to queued user messages') return } - if (opts.queuedCommandsLength > 0) { hookLog('skip: queuedCommands=' + opts.queuedCommandsLength); return } if (opts.hasActiveLocalJsxUI) { hookLog('skip: activeLocalJsxUI'); return } if (opts.isInPlanMode) { hookLog('skip: planMode'); return } @@ -115,7 +124,7 @@ export function useGoalContinuation( enqueue({ value: prompt, mode: 'prompt', - priority: 'later', + priority: 'now', isMeta: true, origin: 'goal-budget-limit', skipSlashCommands: true, @@ -153,7 +162,7 @@ export function useGoalContinuation( enqueue({ value: prompt, mode: 'prompt', - priority: 'later', + priority: 'now', isMeta: true, origin: 'goal-continuation', skipSlashCommands: true, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 607a58aab..a0a63bef3 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -4031,6 +4031,7 @@ export function REPL({ doneOptions?: { display?: CommandResultDisplay; metaMessages?: string[]; + displayArgs?: string; }, ): void => { doneWasCalled = true; @@ -4054,8 +4055,9 @@ export function REPL({ // doesn't change model context). Outside fullscreen the // transcript entry stays so scrollback shows what ran. if (!isFullscreenEnvEnabled()) { + const breadcrumbArgs = doneOptions?.displayArgs ?? commandArgs; newMessages.push( - createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), + createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), breadcrumbArgs)), createCommandInputMessage( `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}`, ), @@ -5931,7 +5933,6 @@ export function REPL({ !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } - {isFullscreenEnvEnabled() && } } bottom={ @@ -5944,6 +5945,7 @@ export function REPL({ ) : null} + {isFullscreenEnvEnabled() && } {permissionStickyFooter} {/* Immediate local-jsx commands (/btw, /sandbox, /assistant, /issue) render here, NOT inside scrollable. They stay mounted diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index cc2075278..e4e9f4443 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -297,11 +297,11 @@ export function ResumeConversation({ } } - if (feature('GOAL') && log.goal && result.sessionId) { + if (feature('GOAL') && result.goal && result.sessionId) { const { hydrateGoalFromTranscript } = require('src/services/goal/goalStorage.js') as typeof import('src/services/goal/goalStorage.js'); const goalsMap = new Map(); - goalsMap.set(result.sessionId, log.goal); + goalsMap.set(result.sessionId, result.goal); hydrateGoalFromTranscript(goalsMap, result.sessionId); } diff --git a/src/types/command.ts b/src/types/command.ts index 5745b7cdf..3e3c802d8 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -122,6 +122,8 @@ export type LocalJSXCommandOnDone = ( metaMessages?: string[] nextInput?: string submitNextInput?: boolean + /** Override the args shown in the command breadcrumb (e.g. truncated). Full args still reach metaMessages. */ + displayArgs?: string }, ) => void diff --git a/src/utils/conversationRecovery.ts b/src/utils/conversationRecovery.ts index c7af7bfcd..d56681777 100644 --- a/src/utils/conversationRecovery.ts +++ b/src/utils/conversationRecovery.ts @@ -507,6 +507,8 @@ export async function loadConversationForResume( prRepository?: string // Full path to the session file (for cross-directory resume) fullPath?: string + // Goal state for hydration on resume + goal?: import('../types/logs.js').GoalState } | null> { try { let log: LogOption | null = null @@ -618,6 +620,8 @@ export async function loadConversationForResume( prRepository: log?.prRepository, // Include full path for cross-directory resume fullPath: log?.fullPath, + // Goal state for hydration on resume + goal: log?.goal, } } catch (error) { logError(error as Error) diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index da6763f7e..d5c44beed 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -740,6 +740,7 @@ async function getMessagesForSlashCommand( metaMessages?: string[]; nextInput?: string; submitNextInput?: boolean; + displayArgs?: string; }, ) => { doneWasCalled = true; @@ -773,20 +774,22 @@ async function getMessagesForSlashCommand( const skipTranscript = isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed'); + const breadcrumbArgs = options?.displayArgs ?? args; + void resolve({ messages: options?.display === 'system' ? skipTranscript ? metaMessages : [ - createCommandInputMessage(formatCommandInput(command, args)), + createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)), createCommandInputMessage(`${result}`), ...metaMessages, ] : [ createUserMessage({ content: prepareUserContent({ - inputString: formatCommandInput(command, args), + inputString: formatCommandInput(command, breadcrumbArgs), precedingInputBlocks, }), }), diff --git a/src/utils/sessionRestore.ts b/src/utils/sessionRestore.ts index 93cded34b..bbd2664e2 100644 --- a/src/utils/sessionRestore.ts +++ b/src/utils/sessionRestore.ts @@ -312,6 +312,7 @@ type ResumeLoadResult = { prNumber?: number prUrl?: string prRepository?: string + goal?: import('../types/logs.js').GoalState } /** @@ -471,6 +472,16 @@ export async function processResumedConversation( opts.forkSession ? { ...result, worktreeSession: undefined } : result, ) + if (feature('GOAL') && result.goal) { + const { hydrateGoalFromTranscript } = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js') + const goalsMap = new Map() + const sid = (opts.sessionIdOverride ?? result.sessionId ?? getSessionId()) as UUID + goalsMap.set(sid, result.goal) + hydrateGoalFromTranscript(goalsMap, sid) + } + if (!opts.forkSession) { // Cd back into the worktree the session was in when it last exited. // Done after restoreSessionMetadata (which caches the worktree state diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 96d731d1c..92e87e5c0 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -3066,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise { } = await loadTranscriptFile(sessionFile) if (messages.size === 0) { - return log + const fallbackGoal = log.sessionId + ? goals.get(log.sessionId as UUID) + : undefined + return fallbackGoal ? { ...log, goal: fallbackGoal } : log } // Find the most recent user/assistant leaf message from the transcript @@ -3077,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise { (msg.type === 'user' || msg.type === 'assistant'), ) if (!mostRecentLeaf) { - return log + const fallbackGoal = log.sessionId + ? goals.get(log.sessionId as UUID) + : undefined + return fallbackGoal ? { ...log, goal: fallbackGoal } : log } // Build the conversation chain from this leaf