From bf932d0ff999829516116ee8789b781395d741c1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 7 May 2026 13:40:40 +0200 Subject: [PATCH] feat(agent): add soft reload command --- .../workspace/commands/sessionCommands.ts | 39 +++++++ .../src/workspace/hook/actions/session.ts | 106 +++++++++++++++++- src/renderer/src/workspace/hook/index.ts | 4 +- 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/features/workspace/commands/sessionCommands.ts b/src/renderer/src/features/workspace/commands/sessionCommands.ts index 6565c9598..4f23fdc22 100644 --- a/src/renderer/src/features/workspace/commands/sessionCommands.ts +++ b/src/renderer/src/features/workspace/commands/sessionCommands.ts @@ -158,6 +158,45 @@ export const sessionCommands: CommandDef[] = [ }, run: ({ workspace }) => void workspace.reloadFocusedAgent(), }, + { + id: 'soft-reload-agent', + title: 'Soft Reload Agent', + description: '**What it does:** Refreshes the focused **agent view** without restarting its backend process.\n\n**Use when:** The feed or rendering state looks stale, duplicated, or corrupted while the agent is still working.\n\n**Notes:** Keeps the same session, draft, pane placement, and running process.', + keywords: [ + 'soft', + 'reload', + 'refresh', + 'render', + 'renderer', + 'agent', + 'view', + 'stale', + 'corrupt', + 'feed', + 'repair', + ], + getState: ({ workspace }) => { + const sessionId = commandTargetSessionId(workspace) + const meta = sessionId ? workspace.state.sessions[sessionId] : null + const kind = meta?.kind ?? 'claude' + return { + label: kind === 'codex' ? 'Codex' : 'Claude', + tone: 'neutral', + } + }, + when: ({ workspace }) => { + const sessionId = commandTargetSessionId(workspace) + if (!sessionId) return false + const meta = workspace.state.sessions[sessionId] + const kind = meta?.kind ?? 'claude' + return kind === 'claude' || kind === 'codex' + }, + run: async ({ workspace, ui }) => { + ui.closePalette() + const sessionId = await workspace.softReloadAgentView() + if (sessionId) workspace.showPaneToast(sessionId, 'Soft reloaded agent view') + }, + }, { id: 'copy-resume-command', title: 'Copy Resume Command', diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index 53bfde5a8..cd0e0f02f 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -52,6 +52,57 @@ export type SessionActions = { opts?: { resumeSessionId?: string; kind?: SessionKind }, ) => Promise reloadAgentSessions: (dangerousMode?: boolean) => Promise + softReloadAgentView: (sessionId?: SessionId) => Promise +} + +function softReloadRuntime(current: SessionRuntime, hasProviderSession: boolean): SessionRuntime { + const reset = emptyRuntime() + + // WHY this is a selective reset instead of a fresh `emptyRuntime()`: + // + // Soft reload is meant for "the renderer/feed got weird while the + // backend is still working". A hard reload can throw the process + // away and resume from disk, but this action must leave the live + // provider process alone. That means process lifecycle, input + // readiness, in-progress phase text, draft text/images, and tail + // preference are still authoritative and must survive. The things + // we discard are renderer-derived caches: visible entries, semantic + // ghosts, screen snapshots, indexes, pickers, and transient prompts. + // + // The history loader runs immediately after this reset when a + // providerSessionId exists. It rebuilds committed transcript rows + // and worktree context from disk, while any IPC events that arrive + // during the load merge into this same runtime. Resetting UUID + // de-dupe state in the caller is the other half of that contract; + // without it, replay would think every old row was already visible. + return { + ...reset, + draftInput: current.draftInput, + draftImages: current.draftImages, + tailMode: current.tailMode, + scrollToLatestRequest: current.scrollToLatestRequest + 1, + processActive: current.processActive, + sessionStatus: current.sessionStatus, + sessionStatusSource: current.sessionStatusSource, + processStatus: current.processStatus, + processError: current.processError, + inputReady: current.inputReady, + exited: current.exited, + projectDir: current.projectDir, + conditions: current.conditions, + activityStatus: current.activityStatus, + unreadSince: current.unreadSince, + unreadKind: current.unreadKind, + streamPhase: current.streamPhase, + streamPhasePendingToolName: current.streamPhasePendingToolName, + streamPhasePendingToolUseId: current.streamPhasePendingToolUseId, + turnStartedAt: current.turnStartedAt, + phaseChangedAt: current.phaseChangedAt, + submittedAt: current.submittedAt, + hasOlderHistory: hasProviderSession, + transcriptStatus: hasProviderSession ? 'loading' : 'ready', + transcriptError: null, + } } export function useSessionActions( @@ -552,5 +603,58 @@ export function useSessionActions( ], ) - return { spawn, killSession, replaceSession, reloadAgentSessions } + const softReloadAgentView = useCallback( + async (sessionIdOverride?: SessionId): Promise => { + const snapshot = refs.stateRef.current + // WHY use command-target focus here: this command is most useful when + // triaging from Dispatch Mode. The row the user has highlighted may be + // detached and absent from the grid's focused leaf, so reading only the + // active tab would refresh the wrong pane. + const sessionId = sessionIdOverride ?? commandTargetSessionIdForState(snapshot) + if (!sessionId) return null + + const meta = snapshot.sessions[sessionId] + if (!meta) return null + const kind = meta.kind ?? 'claude' + if (kind !== 'claude' && kind !== 'codex') return null + + const hasProviderSession = Boolean(meta.providerSessionId) + + const timer = refs.bootstrapTimersRef.current.get(sessionId) + if (timer) { + clearTimeout(timer) + refs.bootstrapTimersRef.current.delete(sessionId) + } + refs.seenUuidsRef.current[sessionId] = new Set() + delete refs.latestScreenRef.current[sessionId] + + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + return { + ...prev, + [sessionId]: softReloadRuntime(current, hasProviderSession), + } + }) + + if (hasProviderSession) { + await loadInitialHistoryForSession({ + sessionId, + meta, + refs, + setRuntimes, + }) + } + + return sessionId + }, + [ + refs.bootstrapTimersRef, + refs.latestScreenRef, + refs.seenUuidsRef, + refs.stateRef, + setRuntimes, + ], + ) + + return { spawn, killSession, replaceSession, reloadAgentSessions, softReloadAgentView } } diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index 329046195..29aa6ab70 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -194,7 +194,8 @@ export function useWorkspace( // Session lifecycle + derivatives that depend on it const sessionActions = useSessionActions(state, setState, setRuntimes, refs) - const { spawn, killSession, replaceSession, reloadAgentSessions } = sessionActions + const { spawn, killSession, replaceSession, reloadAgentSessions, softReloadAgentView } = + sessionActions const tabActions = useTabActions( state, @@ -334,6 +335,7 @@ export function useWorkspace( rotateLayout, replaceSession, reloadFocusedAgent, + softReloadAgentView, switchFocusedProvider, rewindFocusedToPrompt, reloadAgentSessions,