diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx
index 20958c00939..ed31a007bd5 100644
--- a/apps/desktop/src/app/agents/index.tsx
+++ b/apps/desktop/src/app/agents/index.tsx
@@ -4,9 +4,10 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { FadeText } from '@/components/ui/fade-text'
+import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { type Translations, useI18n } from '@/i18n'
-import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
+import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import {
@@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
if (tree.length === 0) {
return (
-
+
{t.agents.emptyTitle}
{t.agents.emptyDesc}
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 2b6586cf5a1..e4a80e61273 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -88,7 +88,10 @@ interface ChatViewProps extends Omit, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise
onReload: (parentId: string | null) => Promise
- onRestoreToMessage?: (messageId: string) => Promise
+ onRestoreToMessage?: (
+ messageId: string,
+ target?: { text?: string; userOrdinal?: number | null }
+ ) => Promise
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise
onDismissError?: (messageId: string) => void
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 8a039f41710..812be0a4867 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -34,6 +34,7 @@ import {
FILE_BROWSER_MIN_WIDTH,
pinSession,
PREVIEW_PANE_ID,
+ restoreWorktree,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
@@ -52,6 +53,8 @@ import {
normalizeProfileKey,
refreshActiveProfile
} from '../store/profile'
+import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
+import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
import {
$activeSessionId,
$attentionSessionIds,
@@ -60,13 +63,14 @@ import {
$gatewayState,
$messages,
$messagingSessions,
- $resumeFailedSessionId,
$resumeExhaustedSessionId,
+ $resumeFailedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
+ getRememberedSessionId,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
@@ -81,6 +85,7 @@ import {
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
+ setRememberedSessionId,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -110,6 +115,8 @@ import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
import { RightSidebarPane } from './right-sidebar'
+import { FileActionDialogs } from './right-sidebar/file-actions'
+import { ReviewPane } from './right-sidebar/review'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
@@ -215,6 +222,7 @@ export function DesktopController() {
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
+ const reviewOpen = useStore($reviewOpen)
const panesFlipped = useStore($panesFlipped)
const profileScope = useStore($profileScope)
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
@@ -283,6 +291,36 @@ export function DesktopController() {
}
}, [])
+ // Remember the open chat so a relaunch reopens it instead of an empty new-chat.
+ useEffect(() => {
+ if (routedSessionId) {
+ setRememberedSessionId(routedSessionId)
+ }
+ }, [routedSessionId])
+
+ // Restore that chat once, on cold start only (we're at the new-chat route and
+ // haven't navigated yet). A dead/deleted id self-clears via the exhausted latch
+ // below, so we never boot-loop into an error screen.
+ const restoredLastSessionRef = useRef(false)
+ useEffect(() => {
+ if (restoredLastSessionRef.current) {
+ return
+ }
+
+ restoredLastSessionRef.current = true
+ const last = getRememberedSessionId()
+
+ if (last && location.pathname === NEW_CHAT_ROUTE) {
+ navigate(sessionRoute(last), { replace: true })
+ }
+ }, [location.pathname, navigate])
+
+ useEffect(() => {
+ if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
+ setRememberedSessionId(null)
+ }
+ }, [resumeExhaustedSessionId])
+
// Notification click: the main process already focused the window; jump to its
// session. Notifications are tagged with the gateway *runtime* session id, but
// the chat route is keyed by the *stored* id — navigating with the runtime id
@@ -476,9 +514,9 @@ export function DesktopController() {
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
- const loadMoreSessions = useCallback(() => {
+ const loadMoreSessions = useCallback(async () => {
bumpSessionsLimit()
- void refreshSessions()
+ await refreshSessions()
}, [refreshSessions])
// Another window mutated the shared session list (e.g. a chat started in the
@@ -551,7 +589,7 @@ export function DesktopController() {
[activeSessionIdRef, updateSessionState]
)
- const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
+ const { refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
@@ -667,6 +705,7 @@ export function DesktopController() {
const {
archiveSession,
branchCurrentSession,
+ branchStoredSession,
createBackendSessionForSend,
openSettings,
removeSession,
@@ -799,7 +838,10 @@ export function DesktopController() {
(path: null | string) => {
startFreshSessionDraft()
- const target = path?.trim()
+ // A worktree lane carries its own path; the trunk "+" can be path-less (the
+ // main checkout is implicit), so fall back to the active project's root
+ // instead of no-op'ing on null — that was "+ on main does nothing".
+ const target = path?.trim() || resolveNewSessionCwd()
if (!target) {
return
@@ -810,14 +852,50 @@ export function DesktopController() {
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
- setCurrentCwd(info.cwd || target)
+ const resolved = info.cwd || target
+
+ setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')
+
+ // An EXPLICIT target (a worktree/lane path — e.g. just-created via
+ // "convert a branch" / "new worktree") drills the sidebar into that
+ // project so the new lane is visible at once. Without this, a brand-new
+ // worktree session is invisible from the all-projects overview (the
+ // live overlay skips `.worktrees` rows, and the session.info cwd-follow
+ // only fires on a same-session move, not a fresh session). The
+ // path-less trunk "+" keeps the current scope untouched.
+ if (path?.trim()) {
+ restoreWorktree(resolved)
+ void followActiveSessionCwd(resolved)
+ }
})
.catch(() => undefined)
},
[requestGateway, startFreshSessionDraft]
)
+ // Composer "branch off into a new worktree": the composer already created the
+ // worktree and cleared its draft; open a fresh session anchored to that tree,
+ // then prefill the task that kicked it off. startSessionInWorkspace owns the
+ // reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp
+ // the cwd back to the default), so the prefill is dispatched right after — its
+ // deferred event lands once the fresh composer has remounted and rebound.
+ const startWorkSessionRequest = useStore($startWorkSessionRequest)
+ const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
+
+ useEffect(() => {
+ if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
+ return
+ }
+
+ lastStartWorkTokenRef.current = startWorkSessionRequest.token
+ startSessionInWorkspace(startWorkSessionRequest.path)
+
+ if (startWorkSessionRequest.draft) {
+ requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
+ }
+ }, [startSessionInWorkspace, startWorkSessionRequest])
+
const handleSkinCommand = useSkinCommand()
const {
@@ -981,6 +1059,7 @@ export function DesktopController() {
void archiveSession(sessionId)}
+ onBranchSession={sessionId => void branchStoredSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
@@ -1031,6 +1110,7 @@ export function DesktopController() {
+
{settingsOpen && (
@@ -1158,14 +1238,43 @@ export function DesktopController() {
side={railSide}
width={FILE_BROWSER_DEFAULT_WIDTH}
>
+ {/* Key on the project (cwd) so switching projects unmounts the old tree and
+ mounts a fresh one straight into its skeleton — no stale-then-blip. */}
composer.insertContextPathInlineRef(path)}
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
- onChangeCwd={changeSessionCwd}
/>
)
+ const reviewPane = (
+
+
+
+ )
+
const terminalPane = (
)
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts
index 9ae7e13976c..992cae14559 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts
@@ -36,7 +36,9 @@ import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
+import { followActiveSessionCwd } from '@/store/projects'
import {
+ $currentCwd,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@@ -46,6 +48,7 @@ import {
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
+ setSessions,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@@ -53,6 +56,7 @@ import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
+import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../types'
@@ -339,6 +343,9 @@ export function useMessageStream({
const nativeSubagentSessionsRef = useRef>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef>(new Set())
+ // Last session we applied a session.info cwd for — lets us tell an agent
+ // relocating the SAME session (follow it) from a session switch (don't yank).
+ const lastCwdInfoSessionRef = useRef(null)
const flushQueuedDeltas = useCallback(
(sessionId?: string) => {
@@ -746,7 +753,20 @@ export function useMessageStream({
}
if (typeof payload?.cwd === 'string') {
+ // The active session's agent can relocate itself (new repo/worktree
+ // via the terminal). When the SAME active session's cwd actually
+ // moves, follow it — refresh the project tree + scope so the sidebar
+ // tracks the live thread. A fresh selection (different session id)
+ // is a switch, not a move, so it refreshes data without yanking scope.
+ const cwdMoved = payload.cwd !== $currentCwd.get()
+ const sameSession = !!sessionId && sessionId === lastCwdInfoSessionRef.current
+
+ lastCwdInfoSessionRef.current = sessionId
setCurrentCwd(payload.cwd)
+
+ if (cwdMoved && sameSession) {
+ void followActiveSessionCwd(payload.cwd)
+ }
}
if (typeof payload?.branch === 'string') {
@@ -923,6 +943,16 @@ export function useMessageStream({
if (payload?.usage) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
+ } else if (event.type === 'session.title') {
+ // Live auto-title push (titler runs async, after the turn's refresh).
+ const storedId = typeof payload?.session_id === 'string' ? payload.session_id : ''
+ const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
+
+ if (storedId && nextTitle) {
+ setSessions(prev =>
+ prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
+ )
+ }
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {
return
@@ -959,6 +989,13 @@ export function useMessageStream({
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
+
+ // A file-mutating tool just finished — nudge the git-mirroring surfaces
+ // (coding rail, review pane, file tree) to refresh. Event-driven, not
+ // polled: fires exactly when the agent touches the tree.
+ if (payload && toolMayMutateFiles(payload)) {
+ notifyWorkspaceChanged()
+ }
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload && !sessionInterrupted(sessionId)) {
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
index 5a3c3241752..62d927050fd 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
@@ -44,7 +44,10 @@ function sessionInfo(overrides: Partial = {}): SessionInfo {
interface HarnessHandle {
cancelRun: () => Promise
- restoreToMessage: (messageId: string) => Promise
+ restoreToMessage: (
+ messageId: string,
+ target?: { text?: string; userOrdinal?: number | null }
+ ) => Promise
steerPrompt: (text: string) => Promise
submitText: (
text: string,
@@ -642,17 +645,45 @@ describe('usePromptActions restoreToMessage', () => {
})
})
- it('ignores non-user targets and unknown ids without touching the gateway', async () => {
+ it('rejects non-user targets and unknown ids without touching the gateway', async () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
- await handle!.restoreToMessage('a1')
- await handle!.restoreToMessage('missing')
+ await expect(handle!.restoreToMessage('a1')).rejects.toThrow('Could not find the message to restore.')
+ await expect(handle!.restoreToMessage('missing')).rejects.toThrow('Could not find the message to restore.')
expect(requestGateway).not.toHaveBeenCalled()
})
+
+ it('uses the clicked runtime user ordinal when the rendered message id is stale', async () => {
+ const requestGateway = vi.fn(async () => ({}) as never)
+
+ let lastState: Record = {}
+ let handle: HarnessHandle | null = null
+ render(
+ (handle = h)}
+ onSeedState={state => (lastState = state)}
+ refreshSessions={async () => undefined}
+ requestGateway={requestGateway}
+ seedMessages={$messages.get()}
+ />
+ )
+
+ await handle!.restoreToMessage('runtime-user-id-not-in-store', {
+ text: 'first prompt',
+ userOrdinal: 0
+ })
+
+ expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
+ session_id: RUNTIME_SESSION_ID,
+ text: 'first prompt',
+ truncate_before_user_ordinal: 0
+ })
+ expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
+ })
})
describe('usePromptActions file attachment sync', () => {
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
index 307fb7e24bb..eaadc3efa92 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
@@ -38,11 +38,11 @@ import {
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
-import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
+import { clearPreviewArtifacts } from '@/store/preview-status'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@@ -157,6 +157,13 @@ async function withSessionBusyRetry(call: () => Promise): Promise {
}
}
+// Hard guard: at most one prompt.submit in flight per session. Every submit
+// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
+// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
+// session whose first call hangs) let the SAME prompt launch several real turns
+// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
+const _submitInFlight = new Set()
+
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
@@ -384,6 +391,31 @@ function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): numb
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
+function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
+ let ordinal = 0
+
+ for (let index = 0; index < messages.length; index += 1) {
+ const message = messages[index]
+
+ if (message.role !== 'user' || message.hidden) {
+ continue
+ }
+
+ if (ordinal === targetOrdinal) {
+ return index
+ }
+
+ ordinal += 1
+ }
+
+ return -1
+}
+
+interface RestoreMessageTarget {
+ text?: string
+ userOrdinal?: number | null
+}
+
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
@@ -599,6 +631,23 @@ export function usePromptActions({
return false
}
+ // One submit in flight per session — drop any concurrent re-fire so a
+ // stalled turn can't stack the same prompt into multiple real turns.
+ const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__'
+
+ if (_submitInFlight.has(submitLockKey)) {
+ return false
+ }
+
+ _submitInFlight.add(submitLockKey)
+ let submitLockReleased = false
+ const releaseSubmitLock = () => {
+ if (!submitLockReleased) {
+ submitLockReleased = true
+ _submitInFlight.delete(submitLockKey)
+ }
+ }
+
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
@@ -609,6 +658,7 @@ export function usePromptActions({
})
const releaseBusy = () => {
+ releaseSubmitLock()
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
@@ -750,6 +800,10 @@ export function usePromptActions({
clearComposerAttachments()
}
+ // Submit landed — the turn now runs (busy stays true), but the submit
+ // window is closed, so release the lock for the next (sequential) send.
+ releaseSubmitLock()
+
return true
} catch (err) {
releaseBusy()
@@ -1644,55 +1698,78 @@ export function usePromptActions({
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
// user turn and everything after it from the session history, then the same
// text is submitted as a fresh turn. Callers confirm before invoking; errors
- // are rethrown so the confirmation dialog can surface them inline.
- // Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
- // can fire while a turn is streaming, interrupt the live turn first — the
- // cooperative interrupt takes a beat, so the shared busy-retry rides it out.
+ // are rethrown so callers can surface failures. Idle rewinds submit directly:
+ // interrupting an idle agent can leave a stale interrupt flag that cancels the
+ // fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
+ // response interrupts + retries through the shared busy gate.
const submitRewindPrompt = useCallback(
- async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
- if (wasRunning) {
+ async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
+ const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
- // Best-effort — the busy-retry below still gates the submit.
+ // Best-effort. The submit path still gates on the gateway state.
}
}
- await withSessionBusyRetry(() =>
+ const submit = () =>
requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
- )
+
+ if (interruptFirst) {
+ await interrupt()
+ }
+
+ try {
+ await submit()
+ } catch (err) {
+ if (!isSessionBusyError(err)) {
+ throw err
+ }
+
+ await interrupt()
+ await withSessionBusyRetry(submit)
+ }
},
[requestGateway]
)
const restoreToMessage = useCallback(
- async (messageId: string) => {
+ async (messageId: string, target?: RestoreMessageTarget) => {
const sessionId = activeSessionId || activeSessionIdRef.current
if (!sessionId) {
- return
+ throw new Error('No active session to restore.')
}
const messages = $messages.get()
- const sourceIndex = messages.findIndex(m => m.id === messageId)
+ const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
+
+ const fallbackIndex =
+ target?.userOrdinal === null || target?.userOrdinal === undefined
+ ? -1
+ : visibleUserIndexAtOrdinal(messages, target.userOrdinal)
+
+ const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
- return
+ throw new Error('Could not find the message to restore.')
}
- const text = chatMessageText(source).trim()
+ const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
- return
+ throw new Error('Cannot restore an empty message.')
}
- const wasRunning = $busy.get()
- const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
+ const truncateBeforeUserOrdinal =
+ target?.userOrdinal === null || target?.userOrdinal === undefined
+ ? visibleUserOrdinal(messages, sourceIndex)
+ : target.userOrdinal
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
@@ -1716,12 +1793,21 @@ export function usePromptActions({
}))
try {
- await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
+ await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
} catch (err) {
+ // The rewind never landed (e.g. the gateway stayed busy past the retry
+ // deadline). Roll the optimistic truncation back to the full original
+ // history so the UI doesn't desync from what's persisted — leaving it
+ // truncated is what made subsequent sends look duplicative.
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
- updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
+ updateSessionState(sessionId, state => ({
+ ...state,
+ busy: false,
+ awaitingResponse: false,
+ messages
+ }))
throw err
}
},
@@ -1747,9 +1833,8 @@ export function usePromptActions({
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
- // new text. It can fire mid-turn, so capture the live state — the submit
- // helper interrupts first when a turn is running.
- const wasRunning = $busy.get()
+ // new text. It can fire mid-turn; submitRewindPrompt always interrupts
+ // first, so a live turn is wound down before the resubmit.
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
@@ -1782,7 +1867,12 @@ export function usePromptActions({
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
- await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
+ await submitRewindPrompt(
+ sessionId,
+ text,
+ isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
+ busyRef.current || $busy.get()
+ )
} catch (err) {
let surfaced = err
@@ -1797,10 +1887,13 @@ export function usePromptActions({
}
}
+ // Roll the optimistic edit/truncation back to the original history so the
+ // UI stays in sync with what's persisted instead of stranding a partial
+ // timeline.
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
- updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
+ updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages }))
notifyError(surfaced, copy.editFailed)
}
},
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
index 23166d806ff..f47b6b62504 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
+++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
@@ -4,7 +4,6 @@ import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
-import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
@@ -283,147 +282,3 @@ describe('resumeSession failure recovery', () => {
expect(resumeParams).not.toHaveProperty('eager_build')
})
})
-
-interface CacheHarnessProps {
- onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise) => void
- requestGateway: (method: string, params?: Record) => Promise
- runtimeIdByStoredSessionIdRef: MutableRefObject