From 62af32efe7c09be2277f45f0fb12463886cc5c9a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 16:40:27 -0500 Subject: [PATCH] feat(desktop): keep active sessions aligned with cwd --- apps/desktop/src/app/agents/index.tsx | 5 +- apps/desktop/src/app/chat/index.tsx | 5 +- apps/desktop/src/app/desktop-controller.tsx | 124 +++++++- .../app/session/hooks/use-message-stream.ts | 37 +++ .../session/hooks/use-prompt-actions.test.tsx | 39 ++- .../app/session/hooks/use-prompt-actions.ts | 143 +++++++-- .../hooks/use-session-actions.test.tsx | 145 --------- .../app/session/hooks/use-session-actions.ts | 279 ++++++++++-------- .../desktop/src/store/composer-status.test.ts | 56 +++- apps/desktop/src/store/composer-status.ts | 98 +++++- 10 files changed, 622 insertions(+), 309 deletions(-) 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> - selectedStoredSessionIdRef: MutableRefObject - sessionStateByRuntimeIdRef: MutableRefObject> -} - -// Harness that lets the test own the two cache maps so it can pre-seed a -// cross-wired runtime-id mapping and observe whether the warm fast-path trusts -// it. Mirrors the production wiring from use-session-state-cache. -function CacheHarness({ - onReady, - requestGateway, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef -}: CacheHarnessProps) { - const ref = (value: T): MutableRefObject => ({ current: value }) - - const actions = useSessionActions({ - activeSessionId: null, - activeSessionIdRef: ref(null), - busyRef: ref(false), - creatingSessionRef: ref(false), - ensureSessionState: () => ({}) as ClientSessionState, - getRouteToken: () => 'token', - navigate: vi.fn() as never, - requestGateway, - runtimeIdByStoredSessionIdRef, - selectedStoredSessionId: null, - selectedStoredSessionIdRef, - sessionStateByRuntimeIdRef, - syncSessionStateToView: vi.fn(), - updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState) - }) - - useEffect(() => { - onReady(actions.resumeSession) - }, [actions.resumeSession, onReady]) - - return null -} - -const clientState = (storedSessionId: string | null): ClientSessionState => createClientSessionState(storedSessionId) - -describe('resumeSession warm-cache mapping integrity', () => { - afterEach(() => { - cleanup() - setResumeFailedSessionId(null) - setMessages([]) - vi.restoreAllMocks() - }) - - it('rejects a cross-wired runtime mapping and falls through to a full resume', async () => { - // A recycled runtime id ('rt-recycled') is mapped to 'stored-A', but its - // cached state actually belongs to a DIFFERENT session ('stored-B') — the - // exact "open chat A, chat B loads" corruption a reaped/respawned pooled - // backend can leave behind. - const runtimeIdByStoredSessionIdRef: MutableRefObject> = { - current: new Map([['stored-A', 'rt-recycled']]) - } - - const sessionStateByRuntimeIdRef: MutableRefObject> = { - current: new Map([['rt-recycled', clientState('stored-B')]]) - } - - const selectedStoredSessionIdRef: MutableRefObject = { current: null } - - const requestGateway = vi.fn(async (method: string, params?: Record) => { - if (method === 'session.resume') { - return { session_id: 'rt-A-fresh', resumed: params?.session_id, messages: [], info: {} } as never - } - - return {} as never - }) - - vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never) - - let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null - render( - (resume = r)} - requestGateway={requestGateway} - runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} - selectedStoredSessionIdRef={selectedStoredSessionIdRef} - sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} - /> - ) - await waitFor(() => expect(resume).not.toBeNull()) - await resume!('stored-A', true) - - // The fast-path did NOT short-circuit on the cross-wired cache — the full - // resume RPC ran, for the session that was actually requested. - const resumeCalls = requestGateway.mock.calls.filter(([method]) => method === 'session.resume') - expect(resumeCalls.length).toBe(1) - expect(resumeCalls[0][1]).toMatchObject({ session_id: 'stored-A' }) - - // The corrupt mapping was purged so it can't mis-resolve again. - expect(runtimeIdByStoredSessionIdRef.current.has('stored-A')).toBe(false) - expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false) - }) - - it('honours a warm cache entry whose stored id matches (no needless refetch)', async () => { - // Correctly-wired mapping: 'rt-A' <-> 'stored-A'. The fast-path should trust - // it and never reach session.resume (only the lightweight usage probe). - const runtimeIdByStoredSessionIdRef: MutableRefObject> = { - current: new Map([['stored-A', 'rt-A']]) - } - - const sessionStateByRuntimeIdRef: MutableRefObject> = { - current: new Map([['rt-A', clientState('stored-A')]]) - } - - const selectedStoredSessionIdRef: MutableRefObject = { current: null } - - const requestGateway = vi.fn(async (method: string) => { - if (method === 'session.usage') { - return { input: 0, output: 0, total: 0 } as never - } - - return {} as never - }) - - let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null - render( - (resume = r)} - requestGateway={requestGateway} - runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} - selectedStoredSessionIdRef={selectedStoredSessionIdRef} - sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} - /> - ) - await waitFor(() => expect(resume).not.toBeNull()) - await resume!('stored-A', true) - - // Fast-path served the session from cache: no full resume RPC, mapping intact. - const methods = requestGateway.mock.calls.map(([method]) => method) - expect(methods).not.toContain('session.resume') - expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A') - }) -}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 0a71d73e65e..c04ff0d6e57 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -13,6 +13,7 @@ import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects' import { $currentCwd, $currentFastMode, @@ -175,20 +176,37 @@ function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: }) } +interface BranchMessage { + content: string + role: ChatMessage['role'] + source: ChatMessage +} + +// The copyable spine of a branch: user/assistant turns that carry text. +const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] => + messages + .map(message => ({ content: chatMessageText(message), role: message.role, source: message })) + .filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user')) + function upsertOptimisticSession( created: SessionCreateResponse, id: string, title: string | null = null, - preview: string | null = null + preview: string | null = null, + parentSessionId: string | null = null, + lastActive?: number ) { - const now = Date.now() / 1000 + const now = lastActive ?? Date.now() / 1000 // Stamp the profile the session was just created on (= the live gateway's // profile) so the scoped sidebar shows the new row immediately instead of // filtering it out as "default" until the aggregator re-fetches. const profileKey = normalizeProfileKey($activeGatewayProfile.get()) const session: SessionInfo = { - cwd: created.info?.cwd ?? null, + // Seed cwd so the grouped sidebar can place the new row in its repo/worktree + // lane immediately (the overlay groups by path); fall back to the workspace + // the session was just started in when the create response omits it. + cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null), ended_at: null, id, input_tokens: 0, @@ -198,6 +216,7 @@ function upsertOptimisticSession( message_count: created.message_count ?? created.messages?.length ?? 0, model: created.info?.model ?? null, output_tokens: 0, + parent_session_id: parentSessionId, preview, profile: profileKey, source: 'tui', @@ -372,6 +391,16 @@ function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } setCurrentPersonality('') } +// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped / +// rotated backend) — the REST transcript 404s with `Session not found`. Distinct +// from a transient/wedged backend (ECONNREFUSED, timeout), which must still +// retry rather than discard the id. +function isSessionGoneError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? '') + + return message.includes('404') || /session not found/i.test(message) +} + export function useSessionActions({ activeSessionId, activeSessionIdRef, @@ -421,7 +450,10 @@ export function useSessionActions({ // is cleared. setCurrentServiceTier('') setYoloActive(false) - setCurrentCwd(workspaceCwdForNewSession()) + // In a project → the repo's default-branch (main worktree) checkout; not in + // a project → detached. So cmd-n "knows" the project instead of inheriting + // whatever linked worktree the last session drifted into. + setCurrentCwd(resolveNewSessionCwd()) setCurrentBranch('') // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) @@ -591,34 +623,9 @@ export function useSessionActions({ // chat view drops the error state and shows the loader again. setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current)) - // A warm cache entry is only trustworthy when it still BELONGS to the - // session being resumed. A pooled profile backend that gets idle-reaped - // and respawned (pruneSecondaryGateways) re-mints runtime ids, so a - // recycled id can resolve to a live-but-DIFFERENT session's cache entry. - // The session.usage 404 guard below only catches a fully-DEAD id — a - // recycled-live id 200s, so an unchecked hit paints the wrong transcript - // under the current route (the "open chat A, chat B loads" bug). On a - // mismatch the mapping is cross-wired: purge both sides and report a miss - // so the caller falls through to a full resume that rebinds a correct id. - const takeWarmCache = (): { runtimeId: string; state: ClientSessionState } | null => { - const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) - const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined + const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) - if (!runtimeId || !state) { - return null - } - - if (state.storedSessionId !== storedSessionId) { - runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) - sessionStateByRuntimeIdRef.current.delete(runtimeId) - - return null - } - - return { runtimeId, state } - } - - if (!takeWarmCache()) { + if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) { setActiveSessionId(null) activeSessionIdRef.current = null setMessages([]) @@ -637,14 +644,10 @@ export function useSessionActions({ await ensureGatewayProfile(sessionProfile) - // Re-check after the profile-resolve / gateway-swap awaits above: the - // cache may have changed, and takeWarmCache re-validates belongs-to and - // purges a cross-wired mapping before we trust the fast-path. - const warmHit = takeWarmCache() + const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId) + const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) - if (warmHit) { - const cachedRuntimeId = warmHit.runtimeId - const cachedState = warmHit.state + if (cachedRuntimeId && cachedState) { const stored = $sessions.get().find(session => session.id === storedSessionId) const cachedViewState = @@ -743,7 +746,6 @@ export function useSessionActions({ ...(watchWindow ? { lazy: true } : {}), ...(sessionProfile ? { profile: sessionProfile } : {}) }) - // The rejection is consumed by the `await` below; this guard only // keeps it from surfacing as unhandled while the prefetch settles. resumePromise.catch(() => undefined) @@ -829,6 +831,8 @@ export function useSessionActions({ // empty transcript. That is the exact state the thread loader latches on // forever (messagesEmpty && !activeSessionId) with no recovery path — // the "open in new window stays stuck loading, even after a nap" bug. + let fallbackError: unknown = null + try { const fallback = await getSessionMessages(storedSessionId, sessionProfile) @@ -837,14 +841,31 @@ export function useSessionActions({ } setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get())) - } catch { + } catch (e) { // Fallback also failed: nothing to paint. Leave whatever messages are // already shown and fall through to arm the resume-failure latch so // use-route-resume re-attempts the resume on the next render / window // focus / gateway reconnect instead of stranding the loader. + fallbackError = e } - if (isCurrentResume() && $messages.get().length === 0) { + if (!isCurrentResume()) { + return + } + + // The session is genuinely gone (deleted, or a stale id from a wiped / + // rotated backend): the resume RPC and the authoritative REST transcript + // both 404. There's nothing to recover — silently drop to a fresh draft + // instead of toasting an error and hot-looping the bounded retry on a + // permanently-dead id. (Booting straight into a no-longer-existent + // last-session id is the common trigger.) + if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) { + startFreshSessionDraft(true) + + return + } + + if ($messages.get().length === 0) { // Arm the self-heal ONLY when the window is still empty: the gateway // resume rejected AND the REST fallback failed to paint a transcript. // That is the exact stranded state the loader latches on @@ -873,93 +894,53 @@ export function useSessionActions({ runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, + startFreshSessionDraft, syncSessionStateToView, updateSessionState ] ) - const branchCurrentSession = useCallback( - async (messageId?: string): Promise => { - const sourceSessionId = activeSessionIdRef.current - - if (!sourceSessionId) { - notify({ - kind: 'warning', - title: copy.nothingToBranch, - message: copy.branchNeedsChat - }) - - return false - } - - if (busyRef.current) { - notify({ - kind: 'warning', - title: copy.sessionBusy, - message: copy.branchStopCurrent - }) - - return false - } - + // Shared fork: create a child session seeded with `branchMessages`, linked to + // `parentStoredId` so it nests under its parent, then make it the active chat. + const forkBranch = useCallback( + async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise => { creatingSessionRef.current = true try { - const currentMessages = $messages.get() - - const targetIndex = messageId - ? currentMessages.findIndex(message => message.id === messageId) - : currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user') - - const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0) - const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length - - const branchMessages = currentMessages - .slice(branchStart, branchEnd) - .map(message => ({ - content: chatMessageText(message), - source: message, - role: message.role - })) - .filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role)) - - if (!branchMessages.length) { - notify({ - kind: 'warning', - title: copy.nothingToBranch, - message: copy.branchNoText - }) - - return false - } - - clearNotifications() - - const cwd = $currentCwd.get().trim() - + // No title: the backend auto-names the branch from its parent's lineage. const branched = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }), messages: branchMessages.map(({ content, role }) => ({ content, role })), - title: copy.branchTitle + ...(parentStoredId && { parent_session_id: parentStoredId }) }) const routedSessionId = branched.stored_session_id ?? branched.session_id const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null + // Draft until submit: nest under the parent at the parent's recency so it + // doesn't bubble to the top until a real message lands (backend persists + // + auto-names it then). The selected row survives refreshes (sessionsToKeep). + const rows = $sessions.get() + const parent = parentStoredId ? rows.find(session => sessionMatchesStoredId(session, parentStoredId)) : null + const siblings = parentStoredId + ? rows.filter(session => session.parent_session_id?.trim() === parentStoredId).length + : 0 setFreshDraftReady(false) - upsertOptimisticSession(branched, routedSessionId, copy.branchTitle, preview) + upsertOptimisticSession( + branched, + routedSessionId, + copy.branchTitle(siblings + 1).toLowerCase(), + preview, + parentStoredId, + parent ? parent.last_active || parent.started_at : undefined + ) ensureSessionState(branched.session_id, routedSessionId) setActiveSessionId(branched.session_id) activeSessionIdRef.current = branched.session_id updateSessionState( branched.session_id, - state => ({ - ...state, - messages: branchMessages.map(({ source }) => source), - busy: false, - awaitingResponse: false - }), + state => ({ ...state, messages: branchMessages.map(({ source }) => source), busy: false, awaitingResponse: false }), routedSessionId ) setSelectedStoredSessionId(routedSessionId) @@ -967,7 +948,6 @@ export function useSessionActions({ navigate(sessionRoute(routedSessionId)) const runtimeInfo = applyRuntimeInfo(branched.info) - patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) if (runtimeInfo) { @@ -985,17 +965,74 @@ export function useSessionActions({ }, 0) } }, - [ - activeSessionIdRef, - busyRef, - copy, - creatingSessionRef, - ensureSessionState, - navigate, - requestGateway, - selectedStoredSessionIdRef, - updateSessionState - ] + [activeSessionIdRef, copy, creatingSessionRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState] + ) + + // Branch the open chat — optionally from a specific message — off its live transcript. + const branchCurrentSession = useCallback( + async (messageId?: string): Promise => { + if (!activeSessionIdRef.current) { + notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNeedsChat }) + + return false + } + + if (busyRef.current) { + notify({ kind: 'warning', title: copy.sessionBusy, message: copy.branchStopCurrent }) + + return false + } + + const messages = $messages.get() + const at = messageId + ? messages.findIndex(message => message.id === messageId) + : messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user') + const start = at >= 0 ? at : Math.max(messages.length - 1, 0) + const end = at >= 0 ? at + 1 : messages.length + const branchMessages = toBranchMessages(messages.slice(start, end)) + + if (!branchMessages.length) { + notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText }) + + return false + } + + clearNotifications() + + return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim()) + }, + [activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef] + ) + + // Branch any listed session, not just the open one. Reads the target's stored + // transcript directly (no resume/active-session dependency), so it works on + // right-click and nests under its parent. + const branchStoredSession = useCallback( + async (storedSessionId: string, sessionProfile?: string | null): Promise => { + clearNotifications() + + const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + const profile = sessionProfile ?? stored?.profile + + try { + await ensureGatewayProfile(profile) + const { messages } = await getSessionMessages(storedSessionId, profile) + const branchMessages = toBranchMessages(toChatMessages(messages)) + + if (!branchMessages.length) { + notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText }) + + return false + } + + return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim()) + } catch (err) { + notifyError(err, copy.branchFailed) + + return false + } + }, + [copy, forkBranch] ) const removeSession = useCallback( @@ -1012,6 +1049,10 @@ export function useSessionActions({ const removedPinId = removed ? sessionPinId(removed) : storedSessionId setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) + // Evict from the project tree's optimistic layer too (the backend snapshot + // still lists it until its next refresh), so grouped + flat views drop the + // row in lockstep. + tombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id]) // Keep $sessionsTotal in sync so the sidebar's "Load N more" footer // doesn't keep claiming the removed row is still on the server. setSessionsTotal(prev => Math.max(0, prev - 1)) @@ -1040,6 +1081,7 @@ export function useSessionActions({ setSessionsTotal(prev => prev + 1) } + untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id]) $pinnedSessionIds.set(previousPinned) if (wasSelected) { @@ -1094,6 +1136,7 @@ export function useSessionActions({ // Soft-hide: drop from the sidebar immediately, keep the data. setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))) + tombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id]) // Archived sessions are hidden by the listSessions(min_messages=1) query // on the next refresh, so they count as "removed" for the load-more // footer math. @@ -1119,6 +1162,7 @@ export function useSessionActions({ setSessionsTotal(prev => prev + 1) } + untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id]) $pinnedSessionIds.set(previousPinned) notifyError(err, copy.archiveFailed) } @@ -1129,6 +1173,7 @@ export function useSessionActions({ return { archiveSession, branchCurrentSession, + branchStoredSession, closeSettings, createBackendSessionForSend, openSettings, diff --git a/apps/desktop/src/store/composer-status.test.ts b/apps/desktop/src/store/composer-status.test.ts index e677dc0bb8c..19373667fe2 100644 --- a/apps/desktop/src/store/composer-status.test.ts +++ b/apps/desktop/src/store/composer-status.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $backgroundStatusBySession, dismissBackgroundProcess, reconcileBackgroundProcesses } from './composer-status' @@ -17,9 +17,17 @@ const items = () => $backgroundStatusBySession.get()[SID] ?? [] describe('reconcileBackgroundProcesses', () => { beforeEach(() => { + // Fake timers so the success self-clear (a real setTimeout) is deterministic + // and never leaks a pending timer between tests. + vi.useFakeTimers() $backgroundStatusBySession.set({}) }) + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + it('maps registry entries to status items', () => { reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)]) @@ -96,4 +104,50 @@ describe('reconcileBackgroundProcesses', () => { expect($backgroundStatusBySession.get()).toEqual({}) }) + + // The self-clear path calls dismissBackgroundProcess, which records the id in + // the module-level dismissed set; use a fresh session per test so that record + // can't bleed into another test's reconcile. + const itemsOf = (sid: string) => $backgroundStatusBySession.get()[sid] ?? [] + + it('self-clears a finished success after a short linger', () => { + reconcileBackgroundProcesses('sess-clear', [exited('a', 0)]) + expect(itemsOf('sess-clear').map(i => i.id)).toEqual(['a']) + + vi.advanceTimersByTime(5_000) + + expect(itemsOf('sess-clear')).toEqual([]) + }) + + it('self-clears a failed task too, but only after a longer linger', () => { + reconcileBackgroundProcesses('sess-fail', [exited('a', 1)]) + + // Still visible after the success window — the failure gets a longer one so + // its exit code stays readable. + vi.advanceTimersByTime(5_000) + expect(itemsOf('sess-fail').map(i => [i.id, i.state])).toEqual([['a', 'failed']]) + + vi.advanceTimersByTime(10_000) + expect(itemsOf('sess-fail')).toEqual([]) + }) + + it('never self-clears a still-running task', () => { + reconcileBackgroundProcesses('sess-run', [running('a')]) + + vi.advanceTimersByTime(60_000) + + expect(itemsOf('sess-run').map(i => i.id)).toEqual(['a']) + }) + + it('arms the self-clear only once a task finishes', () => { + reconcileBackgroundProcesses('sess-arm', [running('a')]) + vi.advanceTimersByTime(60_000) + // Still running after a minute — nothing scheduled yet. + expect(itemsOf('sess-arm').map(i => i.id)).toEqual(['a']) + + reconcileBackgroundProcesses('sess-arm', [exited('a', 0)]) + vi.advanceTimersByTime(5_000) + + expect(itemsOf('sess-arm')).toEqual([]) + }) }) diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index d084eaa44ec..4d20b476b74 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -5,6 +5,7 @@ import type { TodoItem, TodoStatus } from '@/lib/todos' import { $gateway } from './gateway' import { dispatchNativeNotification } from './native-notifications' +import { notifyError } from './notifications' import { $subagentsBySession, type SubagentProgress } from './subagents' import { $todosBySession } from './todos' @@ -38,6 +39,63 @@ export const $backgroundStatusBySession = atom>() +// Finished tasks self-clear so the stack only ever holds running work. Success +// goes quick; failure lingers longer so its exit code stays readable (the output +// also lives in the transcript). A manual X still drops either at once. +const SUCCESS_LINGER_MS = 4_000 +const FAILURE_LINGER_MS = 12_000 +const autoClearTimers = new Map>>() + +function scheduleAutoDismiss(sid: string, id: string, delayMs: number) { + let timers = autoClearTimers.get(sid) + + if (timers?.has(id)) { + return + } + + if (!timers) { + timers = new Map() + autoClearTimers.set(sid, timers) + } + + timers.set( + id, + setTimeout(() => { + autoClearTimers.get(sid)?.delete(id) + dismissBackgroundProcess(sid, id) + }, delayMs) + ) +} + +function cancelAutoDismiss(sid: string, id: string) { + const timers = autoClearTimers.get(sid) + + if (!timers) { + return + } + + const timer = timers.get(id) + + if (timer !== undefined) { + clearTimeout(timer) + timers.delete(id) + } +} + +function cancelAllAutoDismiss(sid: string) { + const timers = autoClearTimers.get(sid) + + if (!timers) { + return + } + + for (const timer of timers.values()) { + clearTimeout(timer) + } + + autoClearTimers.delete(sid) +} + const subToItem = (s: SubagentProgress): ComposerStatusItem => ({ currentTool: s.currentTool, id: s.id, @@ -201,6 +259,24 @@ export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessE } } + // Arm the self-clear on every finished task (failures linger longer); cancel + // it for anything running again or gone from the snapshot. + const finishedDelay = new Map( + next + .filter(item => item.state !== 'running') + .map(item => [item.id, item.state === 'failed' ? FAILURE_LINGER_MS : SUCCESS_LINGER_MS]) + ) + + for (const [id, delay] of finishedDelay) { + scheduleAutoDismiss(sid, id, delay) + } + + for (const id of [...(autoClearTimers.get(sid)?.keys() ?? [])]) { + if (!finishedDelay.has(id)) { + cancelAutoDismiss(sid, id) + } + } + if (next.length === prev.length && next.every((item, i) => item === prev[i])) { return } @@ -227,6 +303,8 @@ export async function refreshBackgroundProcesses(sid: string): Promise { /** X on a finished row: drop it now and keep it dropped across refreshes. */ export function dismissBackgroundProcess(sid: string, id: string) { + cancelAutoDismiss(sid, id) + const dismissed = dismissedBySession.get(sid) ?? new Set() dismissed.add(id) dismissedBySession.set(sid, dismissed) @@ -239,13 +317,17 @@ export function dismissBackgroundProcess(sid: string, id: string) { ) } -/** X on a running row: kill the process for real, then drop the row. */ -export function stopBackgroundProcess(sid: string, id: string) { - void $gateway - .get() - ?.request('process.kill', { process_id: id, session_id: sid }) - .catch(() => undefined) - dismissBackgroundProcess(sid, id) +/** X on a running row: kill the process for real, THEN drop the row. Only drop + * on a confirmed kill — dismissing unconditionally (the old behavior) hid the + * row while the process lived on, stranding rogue tasks. On failure the row + * stays so the user can retry / see it didn't die. */ +export async function stopBackgroundProcess(sid: string, id: string): Promise { + try { + await $gateway.get()?.request('process.kill', { process_id: id, session_id: sid }) + dismissBackgroundProcess(sid, id) + } catch (err) { + notifyError(err, 'Could not stop the process') + } } /** @@ -260,6 +342,8 @@ export function resetSessionBackground(sid: string) { return } + cancelAllAutoDismiss(sid) + const gateway = $gateway.get() const list = $backgroundStatusBySession.get()[sid] ?? [] const dismissed = dismissedBySession.get(sid) ?? new Set()