From 2e2eff05afa72ca62b497ef7dc6fec1dab07a148 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Sun, 11 Oct 2026 21:52:03 +0000 Subject: [PATCH 1/6] feat(sessions): show background activity for shells and child sessions --- .../src/components/message/PromptInput.tsx | 6 +- .../ui/session-status-indicator.test.tsx | 37 +++++ .../ui/session-status-indicator.tsx | 27 +++- frontend/src/contexts/EventContext.test.tsx | 126 +++++++++++++++++- frontend/src/contexts/EventContext.tsx | 88 +++++++++++- .../src/stores/sessionStatusStore.test.ts | 62 ++++++++- frontend/src/stores/sessionStatusStore.ts | 103 +++++++++++++- 7 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/ui/session-status-indicator.test.tsx diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index f8194bc10..198598998 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -12,6 +12,7 @@ import { useUserBash } from '@/stores/userBashStore' import { useSessionAgentStore } from '@/stores/sessionAgentStore' import { useSendErrorStore } from '@/stores/sendErrorStore' import { useRecentCommandsStore } from '@/stores/recentCommandsStore' +import { useHasBackgroundActivity } from '@/stores/sessionStatusStore' import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' import { useMobile } from '@/hooks/useMobile' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -248,6 +249,7 @@ export const PromptInput = memo(forwardRef( }, []) const sessionAgent = useSessionAgent(sessionID, directory) + const hasBackgroundActivity = useHasBackgroundActivity(sessionID) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) const sendPrompt = useSendPrompt(directory) @@ -1540,7 +1542,7 @@ return ( Latest - {isSessionActive && ( + {(isSessionActive || hasBackgroundActivity) && (
@@ -1573,7 +1575,7 @@ return ( )} - {isSessionActive && ( + {(isSessionActive || hasBackgroundActivity) && (
diff --git a/frontend/src/components/ui/session-status-indicator.test.tsx b/frontend/src/components/ui/session-status-indicator.test.tsx new file mode 100644 index 000000000..e8dd9ef21 --- /dev/null +++ b/frontend/src/components/ui/session-status-indicator.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { SessionStatusIndicator } from './session-status-indicator' +import { useSessionStatus } from '@/stores/sessionStatusStore' + +const resetStore = () => { + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), + parents: new Map(), + runningShells: new Map(), + revision: 0, + }) +} + +describe('SessionStatusIndicator background activity', () => { + beforeEach(() => { + resetStore() + }) + + it('shows the background tasks label when idle with a running shell', () => { + useSessionStatus.getState().setShellRunning('shell-1', 'session-a', '/repo') + + render() + + expect(screen.getByLabelText('Background tasks running')).toBeInTheDocument() + }) + + it('renders nothing when idle without background activity', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/ui/session-status-indicator.tsx b/frontend/src/components/ui/session-status-indicator.tsx index d56823fb8..faf667e58 100644 --- a/frontend/src/components/ui/session-status-indicator.tsx +++ b/frontend/src/components/ui/session-status-indicator.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useState } from 'react' -import { useSessionStatusForSession, type SessionStatusType } from '@/stores/sessionStatusStore' +import { useHasBackgroundActivity, useSessionStatusForSession, type SessionStatusType } from '@/stores/sessionStatusStore' interface SessionStatusIndicatorProps { sessionID: string @@ -18,12 +18,14 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ showLabel = false }: SessionStatusIndicatorProps) { const status = useSessionStatusForSession(sessionID) + const hasBackground = useHasBackgroundActivity(sessionID) const [position, setPosition] = useState(0) const [direction, setDirection] = useState(1) const [retryCountdown, setRetryCountdown] = useState(0) + const backgroundOnly = status.type === 'idle' && hasBackground useEffect(() => { - if (status.type !== 'busy' && status.type !== 'retry' && status.type !== 'compact') { + if (status.type === 'idle' && !hasBackground) { return } @@ -43,7 +45,7 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ }, 60) return () => clearInterval(interval) - }, [status.type, direction]) + }, [status.type, hasBackground, direction]) useEffect(() => { if (!showLabel || status.type !== 'retry') { @@ -62,11 +64,11 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ return () => clearInterval(interval) }, [status, showLabel]) - if (status.type === 'idle') { + if (status.type === 'idle' && !hasBackground) { return null } - const getSegmentColor = (index: number, statusType: SessionStatusType['type']) => { + const getSegmentColor = (index: number, statusType: SessionStatusType['type'], background: boolean) => { const distance = Math.abs(index - (position + SCANNER_WIDTH / 2)) const maxDistance = SCANNER_WIDTH / 2 @@ -76,6 +78,13 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ const intensity = Math.max(0, 1 - distance / (maxDistance + 2)) + if (background) { + if (intensity > 0.8) return 'bg-muted-foreground' + if (intensity > 0.5) return 'bg-muted-foreground/70' + if (intensity > 0.2) return 'bg-muted-foreground/40' + return 'bg-muted-foreground/20' + } + if (statusType === 'retry') { if (intensity > 0.8) return 'bg-warning' if (intensity > 0.5) return 'bg-warning/70' @@ -100,12 +109,16 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ const containerGap = showLabel ? 'gap-2' : '' return ( -
+
{Array.from({ length: SCANNER_SEGMENTS }).map((_, i) => (
))}
diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 3ebf199d9..adb11ef57 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({ listPendingPermissions: vi.fn(), listPendingForms: vi.fn(), listActiveSessions: vi.fn(), + listShells: vi.fn(), + getSession: vi.fn(), replyPermission: vi.fn(), replyForm: vi.fn(), cancelForm: vi.fn(), @@ -31,6 +33,8 @@ vi.mock('@/api/opencode', () => ({ listActiveSessions: mocks.listActiveSessions, listPendingPermissions: mocks.listPendingPermissions, listPendingForms: mocks.listPendingForms, + listShells: mocks.listShells, + getSession: mocks.getSession, replyPermission: mocks.replyPermission, replyForm: mocks.replyForm, cancelForm: mocks.cancelForm, @@ -173,11 +177,13 @@ describe('EventProvider permissions and forms', () => { mocks.listPendingPermissions.mockResolvedValue([]) mocks.listPendingForms.mockResolvedValue([]) mocks.listActiveSessions.mockResolvedValue({}) + mocks.listShells.mockResolvedValue([]) + mocks.getSession.mockRejectedValue(new Error('Session not found')) mocks.replyPermission.mockResolvedValue(undefined) mocks.replyForm.mockResolvedValue(undefined) mocks.cancelForm.mockResolvedValue(undefined) mocks.getHealth.mockReturnValue({ isConnected: false, isHealthy: false, lastEventAt: null, isStalled: false }) - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), parents: new Map(), runningShells: new Map(), revision: 0 }) mocks.subscribeGlobalMonitor.mockReturnValue({ dispose: vi.fn(), updateDirectories: vi.fn(), @@ -1733,4 +1739,122 @@ describe('EventProvider permissions and forms', () => { vi.useRealTimers() } }) + + it('records the parent of a session created with a parentID', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.created', data: { sessionID: 'child-1', parentID: 'parent-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().parents.get('child-1')).toBe('parent-1') + }) + + it('tracks a running background shell and clears it when it exits', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ + type: 'shell.created', + data: { info: { id: 'shell-1', status: 'running', metadata: { sessionID: 'session-1' } } }, + directory: '/repo', + }) + }) + expect(useSessionStatus.getState().runningShells.get('shell-1')).toEqual({ + sessionID: 'session-1', + directory: '/repo', + }) + + act(() => { + onEvent({ type: 'shell.exited', data: { id: 'shell-1', status: 'exited' }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().runningShells.has('shell-1')).toBe(false) + }) + + it('clears a running background shell when it is deleted', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ + type: 'shell.created', + data: { info: { id: 'shell-1', status: 'running', metadata: { sessionID: 'session-1' } } }, + directory: '/repo', + }) + }) + expect(useSessionStatus.getState().runningShells.has('shell-1')).toBe(true) + + act(() => { + onEvent({ type: 'shell.deleted', data: { id: 'shell-1' }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().runningShells.has('shell-1')).toBe(false) + }) + + it('resolves active session ancestry from the snapshot, walking up to a known root', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions.mockResolvedValue({ 'child-1': { type: 'running' } }) + mocks.getSession.mockImplementation((sessionID: string) => { + if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1' }) + if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1' }) + return Promise.reject(new Error('Session not found')) + }) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + handleStatusChange(true) + }) + + await waitFor(() => { + expect(useSessionStatus.getState().parents.get('child-1')).toBe('parent-1') + expect(useSessionStatus.getState().parents.get('parent-1')).toBeNull() + }) + }) + + it('populates running shells from the directory snapshot on connect', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listShells.mockResolvedValue([ + { id: 'shell-1', status: 'running', metadata: { sessionID: 'session-1' } }, + { id: 'shell-2', status: 'exited', metadata: { sessionID: 'session-2' } }, + ]) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.listPendingForms).toHaveBeenCalledWith('/repo')) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + handleStatusChange(true) + }) + + await waitFor(() => { + expect(mocks.listShells).toHaveBeenCalledWith('/repo') + expect(useSessionStatus.getState().runningShells.get('shell-1')).toEqual({ + sessionID: 'session-1', + directory: '/repo', + }) + expect(useSessionStatus.getState().runningShells.has('shell-2')).toBe(false) + }) + }) }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index ad23bbc31..a51433fc4 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -4,9 +4,11 @@ import { useNavigate } from 'react-router-dom' import { useQueryClient, useQuery } from '@tanstack/react-query' import { cancelForm, + getSession, listActiveSessions, listPendingForms, listPendingPermissions, + listShells, replyForm, replyPermission, } from '@/api/opencode' @@ -204,6 +206,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const statusPollIntervalRef = useRef | null>(null) const subscriptionRef = useRef | null>(null) const reposRef = useRef(null) + const ancestryInFlightRef = useRef>(new Set()) const { data: repos } = useQuery({ queryKey: ['repos'], @@ -420,6 +423,52 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return [...directories] }, []) + const syncRunningShells = useCallback(async (directories: string[]) => { + await Promise.all(directories.map(async (directory) => { + try { + const shells = await listShells(directory) + useSessionStatus.getState().replaceRunningShells( + directory, + shells.flatMap((shell) => { + const sessionID = shell.metadata.sessionID + if (shell.status !== 'running' || typeof sessionID !== 'string') return [] + return [{ id: shell.id, sessionID }] + }), + ) + } catch (error) { + if (import.meta.env.DEV) { + console.warn(`Failed to fetch running shells for ${directory}:`, error) + } + } + })) + }, []) + + const resolveSessionAncestry = useCallback(async (sessionIDs: string[]) => { + const resolveUp = async (startID: string): Promise => { + let currentID: string | null = startID + while (currentID !== null) { + const parents = useSessionStatus.getState().parents + if (parents.has(currentID) || ancestryInFlightRef.current.has(currentID)) return + const lookupID = currentID + ancestryInFlightRef.current.add(lookupID) + try { + const session = await getSession(lookupID) + useSessionStatus.getState().setSessionParent(lookupID, session.parentID ?? null) + currentID = session.parentID ?? null + } catch (error) { + if (import.meta.env.DEV) { + console.warn(`Failed to resolve session ancestry for ${lookupID}:`, error) + } + return + } finally { + ancestryInFlightRef.current.delete(lookupID) + } + } + } + + await Promise.allSettled(sessionIDs.map((sessionID) => resolveUp(sessionID))) + }, []) + const fetchInitialPendingData = useCallback(async () => { const reposToUse = reposRef.current if (!reposToUse || reposToUse.length === 0) return @@ -452,13 +501,17 @@ export function EventProvider({ children }: { children: React.ReactNode }) { if (becameUnknown.length > 0) { invalidateChildSessionCaches(queryClient, becameUnknown) } + const unresolvedSessionIDs = Object.keys(active).filter( + (sessionID) => !useSessionStatus.getState().parents.has(sessionID), + ) + await resolveSessionAncestry(unresolvedSessionIDs) } catch (error) { useSessionStatus.getState().endStatusSnapshot(snapshotToken) if (import.meta.env.DEV) { console.warn('Failed to fetch active sessions:', error) } } - }, [queryClient]) + }, [queryClient, resolveSessionAncestry]) const stopStatusPoll = useCallback(() => { if (statusPollIntervalRef.current === null) return @@ -555,13 +608,40 @@ export function EventProvider({ children }: { children: React.ReactNode }) { invalidateSessionListCachesDebounced(queryClient, event.directory) break } - case 'session.created': + case 'session.created': { + invalidateSessionListCachesDebounced(queryClient, event.directory) + if (typeof event.data.parentID === 'string') { + useSessionStatus.getState().setSessionParent(event.data.sessionID, event.data.parentID) + } + break + } case 'session.forked': case 'session.renamed': case 'session.moved': case 'session.metadata.updated': invalidateSessionListCachesDebounced(queryClient, event.directory) break + case 'shell.created': { + const info = event.data.info + if ( + info.status === 'running' && + typeof info.metadata.sessionID === 'string' && + event.directory + ) { + useSessionStatus.getState().setShellRunning(info.id, info.metadata.sessionID, event.directory) + } + break + } + case 'shell.exited': { + if (event.data.status !== 'running') { + useSessionStatus.getState().clearShell(event.data.id) + } + break + } + case 'shell.deleted': { + useSessionStatus.getState().clearShell(event.data.id) + break + } case 'credential.updated': case 'credential.switched': case 'integration.updated': @@ -608,6 +688,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { initialFetchDoneRef.current = false fetchInitialPendingData() fetchInitialSessionStatuses() + void syncRunningShells(collectTrackedDirectories()) invalidateChildSessionCaches(queryClient) invalidateQueryKeysDebounced(queryClient, LOCATION_CATALOG_QUERY_KEYS) startStatusPoll() @@ -620,6 +701,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const handleResync = () => { void reconcilePendingActionsForDirectories(collectTrackedDirectories()) void fetchInitialSessionStatuses() + void syncRunningShells(collectTrackedDirectories()) invalidateChildSessionCaches(queryClient) invalidateQueryKeysDebounced(queryClient, LOCATION_CATALOG_QUERY_KEYS) } @@ -640,7 +722,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { subscription.dispose() subscriptionRef.current = null } - }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories]) + }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells]) useEffect(() => { reposRef.current = repos diff --git a/frontend/src/stores/sessionStatusStore.test.ts b/frontend/src/stores/sessionStatusStore.test.ts index 36590561f..b03744210 100644 --- a/frontend/src/stores/sessionStatusStore.test.ts +++ b/frontend/src/stores/sessionStatusStore.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { busyStatusesFromActiveSessions, useSessionStatus } from './sessionStatusStore' +import { busyStatusesFromActiveSessions, hasBackgroundActivity, useSessionStatus } from './sessionStatusStore' const resetStore = () => { useSessionStatus.setState({ @@ -8,6 +8,8 @@ const resetStore = () => { statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), + parents: new Map(), + runningShells: new Map(), revision: 0, }) } @@ -446,4 +448,62 @@ describe('sessionStatusStore', () => { expect(after.knownSessions.has('child-a')).toBe(false) expect(after.outcomes.has('child-a')).toBe(false) }) + + it('reports background activity when the session owns a running shell', () => { + const store = useSessionStatus.getState() + store.setShellRunning('shell-1', 'session-a', '/repo') + + expect(hasBackgroundActivity(useSessionStatus.getState(), 'session-a')).toBe(true) + }) + + it('reports background activity up the parent chain through a busy descendant', () => { + const store = useSessionStatus.getState() + store.setSessionParent('child', 'parent') + store.setSessionParent('parent', 'grandparent') + store.setStatus('child', { type: 'busy' }) + + expect(hasBackgroundActivity(useSessionStatus.getState(), 'parent')).toBe(true) + expect(hasBackgroundActivity(useSessionStatus.getState(), 'grandparent')).toBe(true) + }) + + it('reports no background activity when children are idle', () => { + const store = useSessionStatus.getState() + store.setSessionParent('child', 'parent') + store.setStatus('child', { type: 'idle' }) + + expect(hasBackgroundActivity(useSessionStatus.getState(), 'parent')).toBe(false) + }) + + it('does not hang on a parent cycle', () => { + const store = useSessionStatus.getState() + store.setSessionParent('a', 'b') + store.setSessionParent('b', 'a') + + expect(hasBackgroundActivity(useSessionStatus.getState(), 'a')).toBe(false) + }) + + it('replaces running shells only for the given directory', () => { + const store = useSessionStatus.getState() + store.setShellRunning('shell-a', 'session-a', '/repo-a') + store.setShellRunning('shell-b', 'session-b', '/repo-b') + + store.replaceRunningShells('/repo-a', [{ id: 'shell-c', sessionID: 'session-c' }]) + + const shells = useSessionStatus.getState().runningShells + expect(shells.has('shell-a')).toBe(false) + expect(shells.get('shell-c')).toEqual({ sessionID: 'session-c', directory: '/repo-a' }) + expect(shells.get('shell-b')).toEqual({ sessionID: 'session-b', directory: '/repo-b' }) + }) + + it('forgets a session parent and its running shells', () => { + const store = useSessionStatus.getState() + store.setSessionParent('child-a', 'parent') + store.setShellRunning('shell-1', 'child-a', '/repo') + + store.forgetSession('child-a') + + const after = useSessionStatus.getState() + expect(after.parents.has('child-a')).toBe(false) + expect(after.runningShells.has('shell-1')).toBe(false) + }) }) diff --git a/frontend/src/stores/sessionStatusStore.ts b/frontend/src/stores/sessionStatusStore.ts index 6cb520224..7e9a3fc91 100644 --- a/frontend/src/stores/sessionStatusStore.ts +++ b/frontend/src/stores/sessionStatusStore.ts @@ -13,12 +13,19 @@ interface StatusSnapshotToken { order: number } +interface RunningShell { + sessionID: string + directory: string +} + interface SessionStatusStore { statuses: Map statusCache: Map statusRevisions: Map knownSessions: Set outcomes: Map + parents: Map + runningShells: Map revision: number setStatus: (sessionID: string, status: SessionStatusType) => void setOutcome: (sessionID: string, outcome: ChildOutcome) => void @@ -30,6 +37,10 @@ interface SessionStatusStore { endStatusSnapshot: (token: StatusSnapshotToken) => void getStatus: (sessionID: string) => SessionStatusType clearStatus: (sessionID: string) => void + setSessionParent: (sessionID: string, parentID: string | null) => void + setShellRunning: (shellID: string, sessionID: string, directory: string) => void + clearShell: (shellID: string) => void + replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>) => void forgetSession: (sessionID: string) => void } @@ -70,6 +81,15 @@ const sameSets = (left: Set, right: Set): boolean => { return true } +const sameRunningShells = (left: Map, right: Map): boolean => { + if (left.size !== right.size) return false + for (const [shellID, shell] of right.entries()) { + const existing = left.get(shellID) + if (!existing || existing.sessionID !== shell.sessionID || existing.directory !== shell.directory) return false + } + return true +} + const markSessionKnown = (knownSessions: Set, sessionID: string): Set | null => { if (knownSessions.has(sessionID)) return null const next = new Set(knownSessions) @@ -109,6 +129,28 @@ export function busyStatusesFromActiveSessions(active: Record): ) } +export function hasBackgroundActivity( + state: Pick, + sessionID: string, +): boolean { + const visited = new Set() + const visit = (id: string): boolean => { + if (visited.has(id)) return false + visited.add(id) + for (const shell of state.runningShells.values()) { + if (shell.sessionID === id) return true + } + for (const [childID, parentID] of state.parents.entries()) { + if (parentID !== id) continue + const childStatus = state.statuses.get(childID) + if (childStatus && childStatus.type !== 'idle') return true + if (visit(childID)) return true + } + return false + } + return visit(sessionID) +} + export const useSessionStatus = create((set, get) => { const recordRevisionPatch = ( state: SessionStatusStore, @@ -151,6 +193,8 @@ export const useSessionStatus = create((set, get) => { statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), + parents: new Map(), + runningShells: new Map(), revision: 0, setStatus: (sessionID: string, status: SessionStatusType) => { @@ -341,14 +385,59 @@ export const useSessionStatus = create((set, get) => { }) }, + setSessionParent: (sessionID: string, parentID: string | null) => { + const current = get().parents + if (current.has(sessionID) && current.get(sessionID) === parentID) return + set((state) => { + const parents = new Map(state.parents) + parents.set(sessionID, parentID) + return { parents } + }) + }, + + setShellRunning: (shellID: string, sessionID: string, directory: string) => { + const current = get().runningShells.get(shellID) + if (current && current.sessionID === sessionID && current.directory === directory) return + set((state) => { + const runningShells = new Map(state.runningShells) + runningShells.set(shellID, { sessionID, directory }) + return { runningShells } + }) + }, + + clearShell: (shellID: string) => { + if (!get().runningShells.has(shellID)) return + set((state) => { + const runningShells = new Map(state.runningShells) + runningShells.delete(shellID) + return { runningShells } + }) + }, + + replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>) => { + const current = get().runningShells + const runningShells = new Map(current) + for (const [shellID, shell] of runningShells.entries()) { + if (shell.directory === directory) runningShells.delete(shellID) + } + for (const shell of shells) { + runningShells.set(shell.id, { sessionID: shell.sessionID, directory }) + } + if (sameRunningShells(current, runningShells)) return + set({ runningShells }) + }, + forgetSession: (sessionID: string) => { clearOptimisticActiveTimer(sessionID) const current = get() + const ownsShell = Array.from(current.runningShells.values()).some((shell) => shell.sessionID === sessionID) const hasSession = current.statuses.has(sessionID) || current.statusCache.has(sessionID) || current.knownSessions.has(sessionID) || - current.outcomes.has(sessionID) + current.outcomes.has(sessionID) || + current.parents.has(sessionID) || + ownsShell if (!hasSession && inFlightSnapshots.size === 0) return set((state) => { @@ -356,15 +445,23 @@ export const useSessionStatus = create((set, get) => { const statusCache = new Map(state.statusCache) const knownSessions = new Set(state.knownSessions) const outcomes = new Map(state.outcomes) + const parents = new Map(state.parents) + const runningShells = new Map(state.runningShells) statuses.delete(sessionID) statusCache.delete(sessionID) knownSessions.delete(sessionID) outcomes.delete(sessionID) + parents.delete(sessionID) + for (const [shellID, shell] of runningShells.entries()) { + if (shell.sessionID === sessionID) runningShells.delete(shellID) + } return { statuses, statusCache, knownSessions, outcomes, + parents, + runningShells, ...(recordRevisionPatch(state, sessionID) ?? {}), } }) @@ -385,3 +482,7 @@ export const useChildLifecycleForSession = (sessionID: string | undefined): Back return childLifecycle(status, state.knownSessions.has(sessionID), state.outcomes.get(sessionID)) }) } + +export const useHasBackgroundActivity = (sessionID: string | undefined): boolean => { + return useSessionStatus((state) => (sessionID ? hasBackgroundActivity(state, sessionID) : false)) +} From 5dbf9766a53ef0fe2581dd876c61e16dfcfb5042 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Mon, 12 Oct 2026 01:08:58 +0000 Subject: [PATCH 2/6] fix(sessions): refresh transcript when returning to a session The transcript cache is never re-read when a mounted session page switches to another session or is returned to, so a session that finished while the user was away kept showing its stop button and treated new prompts as queued. Re-read the newest page whenever the session subscription starts with an existing cache, and drop refetchOnMount so a mount performs a single read. --- .../src/hooks/useSessionTranscript.test.tsx | 109 ++++++++++++++++++ frontend/src/hooks/useSessionTranscript.ts | 5 +- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useSessionTranscript.test.tsx b/frontend/src/hooks/useSessionTranscript.test.tsx index dcca76c95..3a1a21c40 100644 --- a/frontend/src/hooks/useSessionTranscript.test.tsx +++ b/frontend/src/hooks/useSessionTranscript.test.tsx @@ -10,6 +10,7 @@ import type { import { useSessionTranscript } from './useSessionTranscript' import { ContextUsageIndicator } from '@/components/session/ContextUsageIndicator' import { applySessionEvent, emptySessionTranscript } from '@/lib/session-projection' +import type { TranscriptCache } from '@/lib/session-projection' import { sessionTranscriptQueryKey } from '@/lib/queryInvalidation' import { ASSISTANT_MESSAGE_ID, @@ -44,6 +45,7 @@ vi.mock('@/lib/opencode-event-stream', async () => { }) const DIRECTORY = '/repo' +const SECOND_SESSION_ID = 'ses_2' interface SnapshotValue { messages: SessionMessageInfo[] @@ -214,6 +216,113 @@ describe('useSessionTranscript', () => { }) }) + it('reads the newest page once on a fresh mount with no cached transcript', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useSessionTranscript(SESSION_ID, DIRECTORY), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.messages).toHaveLength(2)) + + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(1) + }) + + it('refreshes a cached transcript when switching to another session', async () => { + const queryClient = createQueryClient() + const { result, rerender } = renderHook( + ({ sessionID }: { sessionID: string }) => useSessionTranscript(sessionID, DIRECTORY), + { + wrapper: createWrapper(queryClient), + initialProps: { sessionID: SESSION_ID }, + }, + ) + + await waitFor(() => expect(result.current.messages).toHaveLength(2)) + expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(1) + + const staleBusy: TranscriptCache = { + transcript: { messages: seededMessages, pending: [], status: 'busy' }, + } + queryClient.setQueryData(sessionTranscriptQueryKey(SECOND_SESSION_ID), staleBusy) + + rerender({ sessionID: SECOND_SESSION_ID }) + + await waitFor(() => expect(result.current.status).toBe('idle')) + expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(2) + expect(mocks.readSessionSnapshot).toHaveBeenLastCalledWith(SECOND_SESSION_ID) + }) + + it('refreshes a stale cached transcript when the hook remounts', async () => { + const queryClient = createQueryClient() + mocks.readSessionSnapshot.mockResolvedValue({ + messages: seededMessages, + pending: [], + status: 'busy', + }) + const first = renderHook(() => useSessionTranscript(SESSION_ID, DIRECTORY), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(first.result.current.status).toBe('busy')) + first.unmount() + + mocks.readSessionSnapshot.mockResolvedValue({ + messages: seededMessages, + pending: [], + status: 'idle', + }) + const second = renderHook(() => useSessionTranscript(SESSION_ID, DIRECTORY), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(second.result.current.status).toBe('idle')) + expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(2) + }) + + it('applies events buffered during the refresh read triggered by an existing cache', async () => { + const queryClient = createQueryClient() + const settledAssistant: SessionMessageInfo = { + id: ASSISTANT_MESSAGE_ID, + type: 'assistant', + agent: 'build', + model: { id: 'claude-sonnet-4-5', providerID: 'anthropic' }, + content: [], + time: { created: 1020, completed: 1030 }, + } + queryClient.setQueryData(sessionTranscriptQueryKey(SESSION_ID), { + transcript: { + messages: [seededMessages[0], settledAssistant], + pending: [], + status: 'busy', + }, + }) + + const refreshRead = deferred() + mocks.readSessionSnapshot.mockReturnValueOnce(refreshRead.promise) + + const { result } = renderHook(() => useSessionTranscript(SESSION_ID, DIRECTORY), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(1)) + + act(() => { + testTransport().message(executionSequence[1]) + }) + + await act(async () => { + refreshRead.resolve({ + messages: [seededMessages[0], settledAssistant], + pending: [], + status: 'busy', + }) + }) + + await waitFor(() => expect(result.current.status).toBe('idle')) + expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(1) + }) + it('seeds a queued prompt and a busy session from the snapshot on mount', async () => { mocks.readSessionSnapshot.mockResolvedValue({ messages: seededMessages, diff --git a/frontend/src/hooks/useSessionTranscript.ts b/frontend/src/hooks/useSessionTranscript.ts index a911a6372..51605f570 100644 --- a/frontend/src/hooks/useSessionTranscript.ts +++ b/frontend/src/hooks/useSessionTranscript.ts @@ -83,7 +83,6 @@ export function useSessionTranscript(sessionID: string, directory: string) { enabled: Boolean(sessionID && directory), staleTime: Infinity, refetchOnWindowFocus: false, - refetchOnMount: 'always', refetchInterval: isStreamConnected ? false : TRANSCRIPT_FALLBACK_POLL_INTERVAL_MS, }) @@ -155,6 +154,10 @@ export function useSessionTranscript(sessionID: string, directory: string) { onResync: refreshNewestPage, }) + if (queryClient.getQueryData(queryKey)) { + refreshNewestPage() + } + return () => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current) From aec94db9fba50d7babff0069de400e674c22d480 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Mon, 12 Oct 2026 01:08:58 +0000 Subject: [PATCH 3/6] fix(sessions): address background activity review feedback - keep shell state changed by events that arrive while a shell snapshot is in flight, so an exited shell is not restored and a new one is not dropped - resolve session ancestry for the owners of running shells and for new shells - follow already-known parent links when resolving ancestry - sync running shells for repository directories added after the stream connects - announce session and background activity to screen readers from the prompt bar --- .../message/PromptInput.command.test.tsx | 27 ++++ .../src/components/message/PromptInput.tsx | 9 ++ frontend/src/contexts/EventContext.test.tsx | 135 +++++++++++++++++- frontend/src/contexts/EventContext.tsx | 76 ++++++---- .../src/stores/sessionStatusStore.test.ts | 59 +++++++- frontend/src/stores/sessionStatusStore.ts | 120 ++++++++++++++-- 6 files changed, 384 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index 7a9c87236..cd1d8edf7 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => ({ useUserBash: vi.fn(), useSessionAgentStore: vi.fn(), useSendErrorStore: vi.fn(), + useHasBackgroundActivity: vi.fn(), })) const suggestionProps = vi.hoisted(() => ({ isOpen: false, selectedIndex: 0 })) @@ -66,6 +67,10 @@ vi.mock('@/hooks/useSessionAgent', () => ({ useSessionAgent: mocks.useSessionAge vi.mock('@/stores/userBashStore', () => ({ useUserBash: mocks.useUserBash })) vi.mock('@/stores/sessionAgentStore', () => ({ useSessionAgentStore: mocks.useSessionAgentStore })) vi.mock('@/stores/sendErrorStore', () => ({ useSendErrorStore: mocks.useSendErrorStore })) +vi.mock('@/stores/sessionStatusStore', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useHasBackgroundActivity: mocks.useHasBackgroundActivity } +}) vi.mock('@/lib/toast', () => ({ showToast: mocks.showToast })) vi.mock('@/contexts/EventContext', () => ({ @@ -206,6 +211,7 @@ describe('PromptInput command submission', () => { mocks.useUserBash.mockImplementation((selector: (state: unknown) => unknown) => selector({ addUserBashCommand: vi.fn() })) mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: mocks.setAgent })) mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) + mocks.useHasBackgroundActivity.mockReturnValue(false) suggestionProps.isOpen = false suggestionProps.selectedIndex = 0 }) @@ -243,6 +249,27 @@ describe('PromptInput command submission', () => { other.remove() }) + it('announces session activity changes to screen readers', async () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['opencode', 'session', 'test-session', '/test'], sessionInfo()) + const buildPrompt = (props: { isSessionActive?: boolean }) => ( + + + + ) + + const { rerender } = render(buildPrompt({})) + await screen.findByPlaceholderText('Send a message...') + expect(screen.getByRole('status').textContent).toBe('') + + rerender(buildPrompt({ isSessionActive: true })) + expect(screen.getByRole('status')).toHaveTextContent('Session working') + + mocks.useHasBackgroundActivity.mockReturnValue(true) + rerender(buildPrompt({})) + expect(screen.getByRole('status')).toHaveTextContent('Background tasks running') + }) + it('disables submit and does not run a command while the model is not ready', async () => { mocks.useModelSelection.mockReturnValue({ model: null, diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 198598998..4612af3e7 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1473,8 +1473,17 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) + const sessionActivityAnnouncement = isSessionActive + ? 'Session working' + : hasBackgroundActivity + ? 'Background tasks running' + : '' + return (
+
+ {sessionActivityAnnouncement} +
{composerModeStyle && ( { mocks.replyForm.mockResolvedValue(undefined) mocks.cancelForm.mockResolvedValue(undefined) mocks.getHealth.mockReturnValue({ isConnected: false, isHealthy: false, lastEventAt: null, isStalled: false }) - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), parents: new Map(), runningShells: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), parents: new Map(), runningShells: new Map(), shellRevisions: new Map(), shellRevision: 0, revision: 0 }) mocks.subscribeGlobalMonitor.mockReturnValue({ dispose: vi.fn(), updateDirectories: vi.fn(), @@ -1857,4 +1857,135 @@ describe('EventProvider permissions and forms', () => { expect(useSessionStatus.getState().runningShells.has('shell-2')).toBe(false) }) }) + + it('does not let a directory shell snapshot resurrect a shell that exited while it was in flight', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + const deferred = createDeferred>() + mocks.listShells.mockReturnValue(deferred.promise) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.listShells).toHaveBeenCalledWith('/repo')) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'shell.exited', data: { id: 'shell-1', status: 'exited' }, directory: '/repo' }) + }) + + await act(async () => { + deferred.resolve([{ id: 'shell-1', status: 'running', metadata: { sessionID: 'session-1' } }]) + await deferred.promise + }) + + expect(useSessionStatus.getState().runningShells.has('shell-1')).toBe(false) + }) + + it('does not let a directory shell snapshot drop a shell created while it was in flight', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + const deferred = createDeferred>() + mocks.listShells.mockReturnValue(deferred.promise) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.listShells).toHaveBeenCalledWith('/repo')) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ + type: 'shell.created', + data: { info: { id: 'shell-2', status: 'running', metadata: { sessionID: 'session-2' } } }, + directory: '/repo', + }) + }) + + await act(async () => { + deferred.resolve([]) + await deferred.promise + }) + + expect(useSessionStatus.getState().runningShells.get('shell-2')).toEqual({ + sessionID: 'session-2', + directory: '/repo', + }) + }) + + it('resolves ancestry for a shell owner from the directory snapshot', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listShells.mockResolvedValue([ + { id: 'shell-1', status: 'running', metadata: { sessionID: 'child-1' } }, + ]) + mocks.getSession.mockImplementation((sessionID: string) => { + if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1' }) + if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1' }) + return Promise.reject(new Error('Session not found')) + }) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(useSessionStatus.getState().parents.get('child-1')).toBe('parent-1') + }) + + expect(hasBackgroundActivity(useSessionStatus.getState(), 'parent-1')).toBe(true) + }) + + it('continues a known child to parent link up to the grandparent', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions.mockResolvedValue({ 'child-1': { type: 'running' } }) + mocks.getSession.mockImplementation((sessionID: string) => { + if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1', parentID: 'grandparent-1' }) + if (sessionID === 'grandparent-1') return Promise.resolve({ id: 'grandparent-1' }) + return Promise.reject(new Error('Session not found')) + }) + + act(() => { + useSessionStatus.getState().setSessionParent('child-1', 'parent-1') + }) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + handleStatusChange(true) + }) + + await waitFor(() => { + expect(useSessionStatus.getState().parents.get('parent-1')).toBe('grandparent-1') + expect(useSessionStatus.getState().parents.get('grandparent-1')).toBeNull() + }) + expect(mocks.getSession).toHaveBeenCalledWith('parent-1') + }) + + it('syncs running shells for a directory whose repo arrives after connect', async () => { + const reposDeferred = createDeferred>() + mocks.listRepos.mockReturnValueOnce(reposDeferred.promise) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + handleStatusChange(true) + }) + + expect(mocks.listShells).not.toHaveBeenCalled() + + await act(async () => { + reposDeferred.resolve([{ id: 123, fullPath: '/repo' }]) + await reposDeferred.promise + }) + + await waitFor(() => expect(mocks.listShells).toHaveBeenCalledWith('/repo')) + }) }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index a51433fc4..307451829 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -206,6 +206,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const statusPollIntervalRef = useRef | null>(null) const subscriptionRef = useRef | null>(null) const reposRef = useRef(null) + const trackedRepoDirectoriesRef = useRef>(new Set()) const ancestryInFlightRef = useRef>(new Set()) const { data: repos } = useQuery({ @@ -423,32 +424,21 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return [...directories] }, []) - const syncRunningShells = useCallback(async (directories: string[]) => { - await Promise.all(directories.map(async (directory) => { - try { - const shells = await listShells(directory) - useSessionStatus.getState().replaceRunningShells( - directory, - shells.flatMap((shell) => { - const sessionID = shell.metadata.sessionID - if (shell.status !== 'running' || typeof sessionID !== 'string') return [] - return [{ id: shell.id, sessionID }] - }), - ) - } catch (error) { - if (import.meta.env.DEV) { - console.warn(`Failed to fetch running shells for ${directory}:`, error) - } - } - })) - }, []) - const resolveSessionAncestry = useCallback(async (sessionIDs: string[]) => { const resolveUp = async (startID: string): Promise => { let currentID: string | null = startID + const visited = new Set() while (currentID !== null) { + if (visited.has(currentID)) return + visited.add(currentID) + const parents = useSessionStatus.getState().parents - if (parents.has(currentID) || ancestryInFlightRef.current.has(currentID)) return + if (parents.has(currentID)) { + currentID = parents.get(currentID) ?? null + continue + } + if (ancestryInFlightRef.current.has(currentID)) return + const lookupID = currentID ancestryInFlightRef.current.add(lookupID) try { @@ -469,6 +459,27 @@ export function EventProvider({ children }: { children: React.ReactNode }) { await Promise.allSettled(sessionIDs.map((sessionID) => resolveUp(sessionID))) }, []) + const syncRunningShells = useCallback(async (directories: string[]) => { + await Promise.all(directories.map(async (directory) => { + const token = useSessionStatus.getState().beginShellSnapshot() + try { + const shells = await listShells(directory) + const runningShells = shells.flatMap((shell) => { + const sessionID = shell.metadata.sessionID + if (shell.status !== 'running' || typeof sessionID !== 'string') return [] + return [{ id: shell.id, sessionID }] + }) + useSessionStatus.getState().replaceRunningShells(directory, runningShells, token) + await resolveSessionAncestry(runningShells.map((shell) => shell.sessionID)) + } catch (error) { + useSessionStatus.getState().endShellSnapshot(token) + if (import.meta.env.DEV) { + console.warn(`Failed to fetch running shells for ${directory}:`, error) + } + } + })) + }, [resolveSessionAncestry]) + const fetchInitialPendingData = useCallback(async () => { const reposToUse = reposRef.current if (!reposToUse || reposToUse.length === 0) return @@ -501,10 +512,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { if (becameUnknown.length > 0) { invalidateChildSessionCaches(queryClient, becameUnknown) } - const unresolvedSessionIDs = Object.keys(active).filter( - (sessionID) => !useSessionStatus.getState().parents.has(sessionID), - ) - await resolveSessionAncestry(unresolvedSessionIDs) + await resolveSessionAncestry(Object.keys(active)) } catch (error) { useSessionStatus.getState().endStatusSnapshot(snapshotToken) if (import.meta.env.DEV) { @@ -628,7 +636,11 @@ export function EventProvider({ children }: { children: React.ReactNode }) { typeof info.metadata.sessionID === 'string' && event.directory ) { - useSessionStatus.getState().setShellRunning(info.id, info.metadata.sessionID, event.directory) + const sessionID = info.metadata.sessionID + useSessionStatus.getState().setShellRunning(info.id, sessionID, event.directory) + if (!useSessionStatus.getState().parents.has(sessionID)) { + void resolveSessionAncestry([sessionID]) + } } break } @@ -722,15 +734,21 @@ export function EventProvider({ children }: { children: React.ReactNode }) { subscription.dispose() subscriptionRef.current = null } - }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells]) + }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry]) useEffect(() => { reposRef.current = repos + const directories = [...new Set((repos ?? []).map(r => r.fullPath))] + const previousDirectories = trackedRepoDirectoriesRef.current + const addedDirectories = directories.filter((directory) => !previousDirectories.has(directory)) + trackedRepoDirectoriesRef.current = new Set(directories) const sub = subscriptionRef.current if (!sub) return - const directories = [...new Set((repos ?? []).map(r => r.fullPath))] sub.updateDirectories(directories) - }, [repos]) + if (addedDirectories.length > 0) { + void syncRunningShells(addedDirectories) + } + }, [repos, syncRunningShells]) useEffect(() => { if (!repos || repos.length === 0) return diff --git a/frontend/src/stores/sessionStatusStore.test.ts b/frontend/src/stores/sessionStatusStore.test.ts index b03744210..f1a71dce0 100644 --- a/frontend/src/stores/sessionStatusStore.test.ts +++ b/frontend/src/stores/sessionStatusStore.test.ts @@ -10,6 +10,8 @@ const resetStore = () => { outcomes: new Map(), parents: new Map(), runningShells: new Map(), + shellRevisions: new Map(), + shellRevision: 0, revision: 0, }) } @@ -487,7 +489,8 @@ describe('sessionStatusStore', () => { store.setShellRunning('shell-a', 'session-a', '/repo-a') store.setShellRunning('shell-b', 'session-b', '/repo-b') - store.replaceRunningShells('/repo-a', [{ id: 'shell-c', sessionID: 'session-c' }]) + const token = store.beginShellSnapshot() + store.replaceRunningShells('/repo-a', [{ id: 'shell-c', sessionID: 'session-c' }], token) const shells = useSessionStatus.getState().runningShells expect(shells.has('shell-a')).toBe(false) @@ -495,6 +498,60 @@ describe('sessionStatusStore', () => { expect(shells.get('shell-b')).toEqual({ sessionID: 'session-b', directory: '/repo-b' }) }) + it('does not let a shell snapshot resurrect a shell that exited while it was in flight', () => { + const store = useSessionStatus.getState() + store.setShellRunning('shell-1', 'session-a', '/repo') + + const token = store.beginShellSnapshot() + store.clearShell('shell-1') + + useSessionStatus.getState().replaceRunningShells( + '/repo', + [{ id: 'shell-1', sessionID: 'session-a' }], + token, + ) + + expect(useSessionStatus.getState().runningShells.has('shell-1')).toBe(false) + }) + + it('does not let a shell snapshot drop a shell created while it was in flight', () => { + const store = useSessionStatus.getState() + const token = store.beginShellSnapshot() + store.setShellRunning('shell-2', 'session-b', '/repo') + + useSessionStatus.getState().replaceRunningShells('/repo', [], token) + + expect(useSessionStatus.getState().runningShells.get('shell-2')).toEqual({ + sessionID: 'session-b', + directory: '/repo', + }) + }) + + it('records a shell exit for an unknown shell so a snapshot cannot resurrect it', () => { + const store = useSessionStatus.getState() + const token = store.beginShellSnapshot() + store.clearShell('shell-ghost') + + useSessionStatus.getState().replaceRunningShells( + '/repo', + [{ id: 'shell-ghost', sessionID: 'session-a' }], + token, + ) + + expect(useSessionStatus.getState().runningShells.has('shell-ghost')).toBe(false) + }) + + it('clears shell revisions once the last shell snapshot ends', () => { + const store = useSessionStatus.getState() + const token = store.beginShellSnapshot() + store.setShellRunning('shell-1', 'session-a', '/repo') + expect(useSessionStatus.getState().shellRevisions.size).toBe(1) + + store.replaceRunningShells('/repo', [{ id: 'shell-1', sessionID: 'session-a' }], token) + + expect(useSessionStatus.getState().shellRevisions.size).toBe(0) + }) + it('forgets a session parent and its running shells', () => { const store = useSessionStatus.getState() store.setSessionParent('child-a', 'parent') diff --git a/frontend/src/stores/sessionStatusStore.ts b/frontend/src/stores/sessionStatusStore.ts index 7e9a3fc91..7f30d4246 100644 --- a/frontend/src/stores/sessionStatusStore.ts +++ b/frontend/src/stores/sessionStatusStore.ts @@ -13,6 +13,11 @@ interface StatusSnapshotToken { order: number } +interface ShellSnapshotToken { + revision: number + order: number +} + interface RunningShell { sessionID: string directory: string @@ -26,6 +31,8 @@ interface SessionStatusStore { outcomes: Map parents: Map runningShells: Map + shellRevisions: Map + shellRevision: number revision: number setStatus: (sessionID: string, status: SessionStatusType) => void setOutcome: (sessionID: string, outcome: ChildOutcome) => void @@ -40,7 +47,9 @@ interface SessionStatusStore { setSessionParent: (sessionID: string, parentID: string | null) => void setShellRunning: (shellID: string, sessionID: string, directory: string) => void clearShell: (shellID: string) => void - replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>) => void + replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>, token: ShellSnapshotToken) => void + beginShellSnapshot: () => ShellSnapshotToken + endShellSnapshot: (token: ShellSnapshotToken) => void forgetSession: (sessionID: string) => void } @@ -50,6 +59,8 @@ const optimisticActiveTimers = new Map>() const inFlightSnapshots = new Map() let snapshotOrderCounter = 0 let lastAppliedSnapshotOrder = 0 +const inFlightShellSnapshots = new Map() +let shellSnapshotOrderCounter = 0 const clearOptimisticActiveTimer = (sessionID: string): void => { const timer = optimisticActiveTimers.get(sessionID) @@ -90,6 +101,46 @@ const sameRunningShells = (left: Map, right: Map { + inFlightShellSnapshots.set(token.order, token.revision) +} + +const releaseShellSnapshot = (token: ShellSnapshotToken): void => { + inFlightShellSnapshots.delete(token.order) +} + +const oldestInFlightShellSnapshotRevision = (): number | null => { + let oldest: number | null = null + for (const revision of inFlightShellSnapshots.values()) { + if (oldest === null || revision < oldest) oldest = revision + } + return oldest +} + +const prunedShellRevisions = (currentRevisions: Map): Map => { + const oldestSnapshotRevision = oldestInFlightShellSnapshotRevision() + const nextRevisions = new Map() + if (oldestSnapshotRevision === null) return nextRevisions + for (const [shellID, revision] of currentRevisions.entries()) { + if (revision > oldestSnapshotRevision) nextRevisions.set(shellID, revision) + } + return nextRevisions +} + +const shellTouchPatch = ( + state: Pick, + shellIDs: string[], +): Pick | null => { + if (inFlightShellSnapshots.size === 0 || shellIDs.length === 0) return null + let shellRevision = state.shellRevision + const shellRevisions = new Map(state.shellRevisions) + for (const shellID of shellIDs) { + shellRevision += 1 + shellRevisions.set(shellID, shellRevision) + } + return { shellRevisions, shellRevision } +} + const markSessionKnown = (knownSessions: Set, sessionID: string): Set | null => { if (knownSessions.has(sessionID)) return null const next = new Set(knownSessions) @@ -195,6 +246,8 @@ export const useSessionStatus = create((set, get) => { outcomes: new Map(), parents: new Map(), runningShells: new Map(), + shellRevisions: new Map(), + shellRevision: 0, revision: 0, setStatus: (sessionID: string, status: SessionStatusType) => { @@ -397,34 +450,77 @@ export const useSessionStatus = create((set, get) => { setShellRunning: (shellID: string, sessionID: string, directory: string) => { const current = get().runningShells.get(shellID) - if (current && current.sessionID === sessionID && current.directory === directory) return + if (current && current.sessionID === sessionID && current.directory === directory) { + const touch = shellTouchPatch(get(), [shellID]) + if (touch) set(touch) + return + } set((state) => { const runningShells = new Map(state.runningShells) runningShells.set(shellID, { sessionID, directory }) - return { runningShells } + return { runningShells, ...(shellTouchPatch(state, [shellID]) ?? {}) } }) }, clearShell: (shellID: string) => { - if (!get().runningShells.has(shellID)) return + if (!get().runningShells.has(shellID)) { + const touch = shellTouchPatch(get(), [shellID]) + if (touch) set(touch) + return + } set((state) => { const runningShells = new Map(state.runningShells) runningShells.delete(shellID) - return { runningShells } + return { runningShells, ...(shellTouchPatch(state, [shellID]) ?? {}) } }) }, - replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>) => { + replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>, token: ShellSnapshotToken) => { + releaseShellSnapshot(token) + const current = get().runningShells + const currentRevisions = get().shellRevisions const runningShells = new Map(current) + const isTouched = (shellID: string): boolean => { + const revision = currentRevisions.get(shellID) + return revision !== undefined && revision > token.revision + } + for (const [shellID, shell] of runningShells.entries()) { - if (shell.directory === directory) runningShells.delete(shellID) + if (shell.directory !== directory) continue + if (isTouched(shellID)) continue + runningShells.delete(shellID) } for (const shell of shells) { + if (isTouched(shell.id)) continue runningShells.set(shell.id, { sessionID: shell.sessionID, directory }) } - if (sameRunningShells(current, runningShells)) return - set({ runningShells }) + + const nextRevisions = prunedShellRevisions(currentRevisions) + const shellsChanged = !sameRunningShells(current, runningShells) + const revisionsChanged = !sameEntries(currentRevisions, nextRevisions) + if (!shellsChanged && !revisionsChanged) return + + set({ + ...(shellsChanged ? { runningShells } : {}), + ...(revisionsChanged ? { shellRevisions: nextRevisions } : {}), + }) + }, + + beginShellSnapshot: () => { + const token: ShellSnapshotToken = { + revision: get().shellRevision, + order: ++shellSnapshotOrderCounter, + } + registerShellSnapshot(token) + return token + }, + + endShellSnapshot: (token: ShellSnapshotToken) => { + releaseShellSnapshot(token) + if (inFlightShellSnapshots.size > 0) return + if (get().shellRevisions.size === 0) return + set({ shellRevisions: new Map() }) }, forgetSession: (sessionID: string) => { @@ -452,8 +548,11 @@ export const useSessionStatus = create((set, get) => { knownSessions.delete(sessionID) outcomes.delete(sessionID) parents.delete(sessionID) + const removedShellIDs: string[] = [] for (const [shellID, shell] of runningShells.entries()) { - if (shell.sessionID === sessionID) runningShells.delete(shellID) + if (shell.sessionID !== sessionID) continue + runningShells.delete(shellID) + removedShellIDs.push(shellID) } return { statuses, @@ -462,6 +561,7 @@ export const useSessionStatus = create((set, get) => { outcomes, parents, runningShells, + ...(shellTouchPatch(state, removedShellIDs) ?? {}), ...(recordRevisionPatch(state, sessionID) ?? {}), } }) From 59a25cc39ed25af348006420e0a9d9aabc71bc92 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:47:09 -0400 Subject: [PATCH 4/6] test(sessions): flush microtasks instead of fixed sleep in transcript test --- frontend/src/hooks/useSessionTranscript.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useSessionTranscript.test.tsx b/frontend/src/hooks/useSessionTranscript.test.tsx index 3a1a21c40..344c99184 100644 --- a/frontend/src/hooks/useSessionTranscript.test.tsx +++ b/frontend/src/hooks/useSessionTranscript.test.tsx @@ -224,7 +224,7 @@ describe('useSessionTranscript', () => { await waitFor(() => expect(result.current.messages).toHaveLength(2)) - await new Promise((resolve) => setTimeout(resolve, 20)) + await act(async () => {}) expect(mocks.readSessionSnapshot).toHaveBeenCalledTimes(1) }) From 964c6c2f6ee7540942c7bb45c2a5c4fdbf106cdb Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:58:49 -0400 Subject: [PATCH 5/6] test(e2e): cover session background activity indicator --- e2e/background-activity.spec.ts | 139 ++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 e2e/background-activity.spec.ts diff --git a/e2e/background-activity.spec.ts b/e2e/background-activity.spec.ts new file mode 100644 index 000000000..28b4e2075 --- /dev/null +++ b/e2e/background-activity.spec.ts @@ -0,0 +1,139 @@ +import type { APIRequestContext, Page } from '@playwright/test' +import { test, expect } from './fixtures' + +const SHELL_COMMAND = 'sleep 300' + +function locationQuery(directory: string): string { + return new URLSearchParams({ 'location[directory]': directory }).toString() +} + +function backgroundIndicator(page: Page) { + return page.getByRole('main').getByLabel('Background tasks running') +} + +function backgroundStatus(page: Page) { + return page.getByRole('status').filter({ hasText: 'Background tasks running' }) +} + +async function requireOk(response: Awaited>, label: string): Promise { + if (!response.ok()) { + throw new Error(`${label} failed: ${response.status()} ${await response.text()}`) + } + return response.json() +} + +async function createSession(request: APIRequestContext, directory: string, parentID?: string): Promise { + const body = await requireOk( + await request.post('/api/opencode/api/session', { + data: { location: { directory }, ...(parentID ? { parentID } : {}) }, + }), + 'create session', + ) + return (body as { data: { id: string } }).data.id +} + +async function startBackgroundShell( + request: APIRequestContext, + directory: string, + sessionID: string, +): Promise { + const body = await requireOk( + await request.post(`/api/opencode/api/shell?${locationQuery(directory)}`, { + data: { command: SHELL_COMMAND, timeout: 0, metadata: { sessionID, background: true } }, + }), + 'start background shell', + ) + return (body as { data: { id: string } }).data.id +} + +async function removeShell(request: APIRequestContext, directory: string, shellID: string): Promise { + const response = await request.delete(`/api/opencode/api/shell/${shellID}?${locationQuery(directory)}`) + if (!response.ok() && response.status() !== 404) { + throw new Error(`remove shell failed: ${response.status()} ${await response.text()}`) + } +} + +const createdShells: Array<{ directory: string; id: string }> = [] + +test.afterEach(async ({ request }) => { + const shells = createdShells.splice(0) + await Promise.allSettled(shells.map(({ directory, id }) => removeShell(request, directory, id))) +}) + +test.describe('session background activity', () => { + test('shows the background indicator for a shell running before page load and clears it when removed', async ({ + page, + request, + repo, + }) => { + const sessionId = await createSession(request, repo.fullPath) + const shellId = await startBackgroundShell(request, repo.fullPath, sessionId) + createdShells.push({ directory: repo.fullPath, id: shellId }) + + await page.goto(`/repos/${repo.id}/sessions/${sessionId}`) + await expect(page.getByPlaceholder('Send a message...')).toBeVisible() + + await expect(backgroundIndicator(page)).toBeVisible() + await expect(backgroundStatus(page)).toBeAttached() + + await removeShell(request, repo.fullPath, shellId) + + await expect(backgroundIndicator(page)).toBeHidden() + await expect(backgroundStatus(page)).toHaveCount(0) + }) + + test('shows the background indicator when a shell starts while the page is open and clears it when removed', async ({ + page, + request, + repo, + }) => { + const sessionId = await createSession(request, repo.fullPath) + + await page.goto(`/repos/${repo.id}/sessions/${sessionId}`) + await expect(page.getByPlaceholder('Send a message...')).toBeVisible() + await expect(backgroundIndicator(page)).toHaveCount(0) + + const shellId = await startBackgroundShell(request, repo.fullPath, sessionId) + createdShells.push({ directory: repo.fullPath, id: shellId }) + + await expect(backgroundIndicator(page)).toBeVisible() + await expect(backgroundStatus(page)).toBeAttached() + + await removeShell(request, repo.fullPath, shellId) + + await expect(backgroundIndicator(page)).toBeHidden() + await expect(backgroundStatus(page)).toHaveCount(0) + }) + + test('shows the background indicator on a parent session when a child session owns the running shell', async ({ + page, + request, + repo, + }) => { + const parentId = await createSession(request, repo.fullPath) + const childId = await createSession(request, repo.fullPath, parentId) + const shellId = await startBackgroundShell(request, repo.fullPath, childId) + createdShells.push({ directory: repo.fullPath, id: shellId }) + + await page.goto(`/repos/${repo.id}/sessions/${parentId}`) + await expect(page.getByPlaceholder('Send a message...')).toBeVisible() + + await expect(backgroundIndicator(page)).toBeVisible() + await expect(backgroundStatus(page)).toBeAttached() + + await removeShell(request, repo.fullPath, shellId) + + await expect(backgroundIndicator(page)).toBeHidden() + await expect(backgroundStatus(page)).toHaveCount(0) + }) + + test('shows no background indicator for a session with no running shells', async ({ page, request, repo }) => { + const sessionId = await createSession(request, repo.fullPath) + + await page.goto(`/repos/${repo.id}/sessions/${sessionId}`) + await expect(page.getByPlaceholder('Send a message...')).toBeVisible() + + await expect(backgroundIndicator(page)).toHaveCount(0) + await expect(backgroundStatus(page)).toHaveCount(0) + }) +}) From 901bcd092e7c2b675abaadc42b53ea9505dc137c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:11:45 -0400 Subject: [PATCH 6/6] test(e2e): fail background activity cleanup when a shell removal is rejected --- e2e/background-activity.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/background-activity.spec.ts b/e2e/background-activity.spec.ts index 28b4e2075..ddd6d44a5 100644 --- a/e2e/background-activity.spec.ts +++ b/e2e/background-activity.spec.ts @@ -57,7 +57,9 @@ const createdShells: Array<{ directory: string; id: string }> = [] test.afterEach(async ({ request }) => { const shells = createdShells.splice(0) - await Promise.allSettled(shells.map(({ directory, id }) => removeShell(request, directory, id))) + const results = await Promise.allSettled(shells.map(({ directory, id }) => removeShell(request, directory, id))) + const failures = results.flatMap((result) => (result.status === 'rejected' ? [String(result.reason)] : [])) + expect(failures, 'background shell cleanup failed').toEqual([]) }) test.describe('session background activity', () => {