feat(desktop): keep active sessions aligned with cwd

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:27 -05:00
parent 68680db10d
commit 62af32efe7
10 changed files with 622 additions and 309 deletions

View file

@ -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 (
<div className="grid place-items-center gap-3 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/60" />
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
</div>

View file

@ -88,7 +88,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRestoreToMessage?: (
messageId: string,
target?: { text?: string; userOrdinal?: number | null }
) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void

View file

@ -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() {
<ChatSidebar
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onBranchSession={sessionId => void branchStoredSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
@ -1031,6 +1110,7 @@ export function DesktopController() {
<CommandPalette />
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
{settingsOpen && (
<Suspense fallback={null}>
@ -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. */}
<RightSidebarPane
key={currentCwd || 'no-cwd'}
onActivateFile={path => composer.insertContextPathInlineRef(path)}
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
onChangeCwd={changeSessionCwd}
/>
</Pane>
)
const reviewPane = (
<Pane
defaultOpen
// The diff pane only makes sense in a workspace, so force it shut when the
// session is detached — "No diffs" then only ever shows inside a project,
// never as a second empty panel next to the file browser.
// Docked (wide): `reviewOpen` gates it. Narrow: drop `reviewOpen` from the
// gate so the pane stays mounted as a collapsed overlay — `toggleReview`
// then slides it in/out via the forced-reveal pin, exactly like ⌘B for the
// sidebar. Still requires a repo (no diffs to show otherwise).
disabled={!chatOpen || !currentCwd.trim() || (!narrowViewport && !reviewOpen)}
forceCollapsed={narrowViewport}
hoverReveal
id={REVIEW_PANE_ID}
key="review"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
// Mobile overlay sits at its min width — compact, doesn't bury the chat.
overlayWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side={railSide}
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<ReviewPane key={currentCwd || 'no-cwd'} />
</Pane>
)
const terminalPane = (
<Pane
defaultOpen
@ -1258,6 +1367,7 @@ export function DesktopController() {
*/}
{panesFlipped ? fileBrowserPane : terminalPane}
{previewPane}
{reviewPane}
{panesFlipped ? terminalPane : fileBrowserPane}
</AppShell>
)

View file

@ -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<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(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 | string>(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)) {

View file

@ -44,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string) => Promise<void>
restoreToMessage: (
messageId: string,
target?: { text?: string; userOrdinal?: number | null }
) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
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(<Harness onReady={h => (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<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (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', () => {

View file

@ -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<T>(call: () => Promise<T>): Promise<T> {
}
}
// 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<string>()
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)
}
},

View file

@ -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<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionIdRef: MutableRefObject<string | null>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
}
// 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 = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(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<Map<string, string>> = {
current: new Map([['stored-A', 'rt-recycled']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-recycled', clientState('stored-B')]])
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<unknown>) | null = null
render(
<CacheHarness
onReady={r => (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<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', clientState('stored-A')]])
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { 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<unknown>) | null = null
render(
<CacheHarness
onReady={r => (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')
})
})

View file

@ -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<boolean> => {
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<boolean> => {
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<SessionCreateResponse>('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<boolean> => {
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<boolean> => {
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,

View file

@ -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([])
})
})

View file

@ -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<Record<string, ComposerStatusItem
// while, so without this every refresh would resurrect a dismissed row.
const dismissedBySession = new Map<string, Set<string>>()
// 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<string, Map<string, ReturnType<typeof setTimeout>>>()
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<void> {
/** 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<string>()
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<void> {
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<string>()