- {rows.length === 0 ? (
-
- {isPickerLoading
- ? 'Loading sessions...'
- : isSearchPending
- ? 'Searching sessions...'
- : trimmedQuery
- ? 'No sessions found'
- : 'No sessions yet'}
+ {isFetchNextPageError && (
+
+
Failed to load more sessions
+
- ) : (
- <>
- {sections.map((section) => (
-
-
- {section.heading}
-
- {section.rows.map((row) => {
- const index = rowIndexByKey.get(row.key) ?? -1
- return (
-
- )
- })}
-
- ))}
- {isFetchNextPageError && (
-
-
Failed to load more sessions
-
-
- )}
- {isFetchingNextPage && rows.length > 0 && (
-
Loading more sessions...
- )}
- >
)}
-
+ {isFetchingNextPage && rows.length > 0 && (
+
Loading more sessions...
+ )}
+
↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'}
@@ -694,6 +908,211 @@ function SessionPickerContent({
)
}
+interface ActivePickerContentProps {
+ open: boolean
+ activeSessionID?: string
+ onSelectSession: (session: Session, repo: Repo) => void
+ onDeleteActiveSession: () => void
+ switchHint: string
+ searchInputRef: React.RefObject
+}
+
+function ActivePickerContent({
+ open,
+ activeSessionID,
+ onSelectSession,
+ onDeleteActiveSession,
+ switchHint,
+ searchInputRef,
+}: ActivePickerContentProps) {
+ const windowMs = useActivityWindowMs()
+ const windowMinutes = Math.round(windowMs / MS_PER_MINUTE)
+ const { groups, isLoading, isError } = useActiveWork(windowMs)
+
+ const listId = useId()
+ const optionBaseId = useId()
+
+ const [query, setQuery] = useState('')
+ const trimmedQuery = query.trim()
+
+ const sections = useMemo(() => {
+ const definitions: Array<{ id: string; heading: string; items: ActiveWorkItem[] }> = [
+ { id: 'needs-input', heading: 'Needs input', items: groups.needsInput },
+ { id: 'running', heading: 'Running', items: groups.running },
+ { id: 'recent', heading: 'Recent', items: groups.recent },
+ ]
+ const needle = trimmedQuery.toLowerCase()
+ return definitions.flatMap((definition) => {
+ const rows: SessionPickerRow[] = []
+ for (const item of definition.items) {
+ if (needle && !(item.session.title ?? '').toLowerCase().includes(needle)) continue
+ rows.push({ key: item.key, session: item.session, repo: item.repo })
+ }
+ return rows.length > 0 ? [{ id: definition.id, heading: definition.heading, rows }] : []
+ })
+ }, [groups, trimmedQuery])
+
+ const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections])
+ const activeRow = useMemo(
+ () => rows.find((row) => row.session.id === activeSessionID),
+ [rows, activeSessionID],
+ )
+ const defaultCursorKey = !trimmedQuery && activeRow ? activeRow.key : rows[0]?.key ?? null
+
+ const {
+ rowIndexByKey,
+ cursorIndex,
+ cursorRow,
+ pendingDeleteKey,
+ setPendingDeleteKey,
+ registerRef,
+ moveCursor,
+ resetCursor,
+ } = useSessionPickerCursor(rows, defaultCursorKey)
+
+ useEffect(() => {
+ resetCursor()
+ }, [trimmedQuery, resetCursor])
+
+ const { data: sessionPins } = useSessionPins()
+ const togglePin = useToggleSessionPin()
+ const pinnedKeys = useMemo(() => buildPinnedSessionKeys(sessionPins ?? []), [sessionPins])
+
+ const directories = useMemo(
+ () => Array.from(new Set(rows.map((row) => row.session.location.directory))),
+ [rows],
+ )
+ const deleteSession = useDeleteSession(directories)
+
+ const [sessionToDelete, setSessionToDelete] = useState(null)
+
+ const openRow = useCallback(
+ (row: SessionPickerRow | undefined) => {
+ if (!row) return
+ onSelectSession(row.session, row.repo)
+ },
+ [onSelectSession],
+ )
+
+ const performDelete = useCallback(
+ (session: Session) => {
+ if (session.id === activeSessionID) onDeleteActiveSession()
+ return deleteSession
+ .mutateAsync({ id: session.id, directory: session.location.directory })
+ .catch(() => undefined)
+ },
+ [deleteSession, activeSessionID, onDeleteActiveSession],
+ )
+
+ const deleteRow = useCallback(
+ (row: SessionPickerRow | undefined) => {
+ if (!row) return
+ if (pendingDeleteKey !== row.key) {
+ setPendingDeleteKey(row.key)
+ return
+ }
+ void performDelete(row.session).finally(() => {
+ setPendingDeleteKey(null)
+ })
+ },
+ [pendingDeleteKey, performDelete, setPendingDeleteKey],
+ )
+
+ const handleSearchKeyDown = useSessionPickerKeyNav({
+ rows,
+ cursorIndex,
+ cursorRow,
+ moveCursor,
+ onOpenRow: openRow,
+ onDeleteRow: deleteRow,
+ })
+
+ const handleTogglePin = useCallback(
+ (row: SessionPickerRow) => {
+ togglePin.mutate({
+ sessionId: row.session.id,
+ directory: row.session.location.directory,
+ pinned: !pinnedKeys.has(row.key),
+ })
+ },
+ [togglePin, pinnedKeys],
+ )
+
+ const handleRequestDelete = useCallback((session: Session) => {
+ setSessionToDelete(session)
+ }, [])
+
+ const confirmDelete = useCallback(() => {
+ if (!sessionToDelete) return
+ void performDelete(sessionToDelete).finally(() => {
+ setSessionToDelete(null)
+ })
+ }, [sessionToDelete, performDelete])
+
+ const emptyState = isLoading
+ ? 'Loading sessions...'
+ : isError
+ ? 'Failed to load active sessions'
+ : trimmedQuery
+ ? 'No sessions found'
+ : `No active or recent sessions in the last ${windowMinutes} minutes`
+
+ return (
+ <>
+
+
= 0 ? optionId(optionBaseId, cursorIndex) : undefined}
+ value={query}
+ onChange={setQuery}
+ onKeyDown={handleSearchKeyDown}
+ placeholder="Search sessions..."
+ name="active-picker-search"
+ />
+
+
+
+
+ ↑↓ move · Enter open · Ctrl+D delete
+ {switchHint ? ` · ${switchHint} sessions` : ''}
+
+
+
+ {
+ if (!nextOpen) setSessionToDelete(null)
+ }}
+ onConfirm={() => {
+ void confirmDelete()
+ }}
+ onCancel={() => setSessionToDelete(null)}
+ isDeleting={deleteSession.isPending}
+ />
+ >
+ )
+}
+
export function SessionPickerDialog({
open,
onOpenChange,
@@ -704,6 +1123,7 @@ export function SessionPickerDialog({
onSelectRepo,
}: SessionPickerDialogProps) {
const searchInputRef = useRef(null)
+ const activeSearchInputRef = useRef(null)
const projectSearchInputRef = useRef(null)
const focusSearchAfterSwitchRef = useRef(false)
const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY)
@@ -729,7 +1149,7 @@ export function SessionPickerDialog({
const toggleTab = useCallback(() => {
focusSearchAfterSwitchRef.current = true
- setTab((current) => (current === 'sessions' ? 'projects' : 'sessions'))
+ setTab((current) => nextSessionPickerTab(current))
}, [])
useShortcutActions({ switchPickerTab: open ? toggleTab : undefined })
@@ -743,6 +1163,7 @@ export function SessionPickerDialog({
const frame = requestAnimationFrame(() => {
focusSearchAfterSwitchRef.current = false
if (tab === 'projects') projectSearchInputRef.current?.focus()
+ else if (tab === 'active') activeSearchInputRef.current?.focus()
else searchInputRef.current?.focus()
})
return () => cancelAnimationFrame(frame)
@@ -769,12 +1190,26 @@ export function SessionPickerDialog({
className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0"
>
- {tab === 'sessions' ? 'Sessions' : 'Projects'}
+ {SESSION_PICKER_TAB_LABELS[tab]}
+ Active
Sessions
Projects
+
+
+
> = {}) {
+ vi.mocked(useSettings).mockReturnValue(createUseSettingsMock({ preferences: basePreferences, ...overrides }))
+}
+
+describe('ActivitySettings', () => {
+ beforeAll(() => {
+ Element.prototype.hasPointerCapture ??= () => false
+ Element.prototype.setPointerCapture ??= () => {}
+ Element.prototype.releasePointerCapture ??= () => {}
+ })
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows the default window when the preference is unset', () => {
+ mockUseSettings()
+ render()
+
+ expect(screen.getByRole('combobox', { name: 'Recent activity window' })).toHaveTextContent('30 minutes')
+ })
+
+ it('shows the stored window', () => {
+ mockUseSettings({ preferences: { ...basePreferences, activityWindowMinutes: 120 } })
+ render()
+
+ expect(screen.getByRole('combobox', { name: 'Recent activity window' })).toHaveTextContent('2 hours')
+ })
+
+ it('saves the chosen window in minutes', async () => {
+ const user = userEvent.setup()
+ const updateSettings = vi.fn()
+ mockUseSettings({ updateSettings })
+ render()
+
+ await user.click(screen.getByRole('combobox', { name: 'Recent activity window' }))
+ await user.click(screen.getByRole('option', { name: '4 hours' }))
+
+ expect(updateSettings).toHaveBeenCalledTimes(1)
+ expect(updateSettings).toHaveBeenCalledWith({ activityWindowMinutes: 240 })
+ })
+})
diff --git a/frontend/src/components/settings/ActivitySettings.tsx b/frontend/src/components/settings/ActivitySettings.tsx
new file mode 100644
index 000000000..82166205c
--- /dev/null
+++ b/frontend/src/components/settings/ActivitySettings.tsx
@@ -0,0 +1,40 @@
+import { useSettings } from '@/hooks/useSettings'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { ACTIVITY_WINDOW_MINUTE_OPTIONS, DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings'
+import { formatActivityWindow } from '@/lib/activeWork'
+
+export function ActivitySettings() {
+ const { preferences, updateSettings } = useSettings()
+ const activityWindowMinutes = preferences?.activityWindowMinutes ?? DEFAULT_ACTIVITY_WINDOW_MINUTES
+
+ return (
+
+
Activity
+
+
+
+
+
+ How far back the Activity panel lists recently active sessions.
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/settings/GeneralSettings.tsx b/frontend/src/components/settings/GeneralSettings.tsx
index 45d3a278c..e5079b687 100644
--- a/frontend/src/components/settings/GeneralSettings.tsx
+++ b/frontend/src/components/settings/GeneralSettings.tsx
@@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch'
import { SessionAutomationSettings } from './SessionAutomationSettings'
import { WalkthroughSettings } from './WalkthroughSettings'
+import { ActivitySettings } from './ActivitySettings'
export function GeneralSettings() {
const { preferences, isLoading, updateSettings, isUpdating } = useSettings()
@@ -199,6 +200,8 @@ export function GeneralSettings() {
+
+
)
}
diff --git a/frontend/src/components/ui/activity-spinner.test.tsx b/frontend/src/components/ui/activity-spinner.test.tsx
new file mode 100644
index 000000000..69a9eaebe
--- /dev/null
+++ b/frontend/src/components/ui/activity-spinner.test.tsx
@@ -0,0 +1,144 @@
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { stubMatchMedia } from '@/test/test-utils'
+import { ActivitySpinner, SPINNER_FRAMES, brailleDots } from './activity-spinner'
+
+const advanceFrames = (count: number) => {
+ act(() => {
+ vi.advanceTimersByTime(80 * count)
+ })
+}
+
+const circles = (root: ParentNode) => Array.from(root.querySelectorAll('circle'))
+
+const litIndexes = (root: ParentNode) =>
+ circles(root)
+ .map((circle, index) => (circle.getAttribute('fill-opacity') === '1' ? index : -1))
+ .filter((index) => index >= 0)
+
+const litIndexesFor = (frame: string) =>
+ brailleDots(frame)
+ .map((lit, index) => (lit ? index : -1))
+ .filter((index) => index >= 0)
+
+const pattern = (root: ParentNode) => circles(root).map((circle) => circle.getAttribute('fill-opacity'))
+
+describe('brailleDots', () => {
+ it('lights dots 1, 2 and 4 for the first frame', () => {
+ expect(brailleDots('⠋')).toEqual([true, true, false, true, false, false, false, false])
+ })
+
+ it('lights dots 1 through 4 for the fourth frame', () => {
+ expect(brailleDots('⠏')).toEqual([true, true, true, true, false, false, false, false])
+ })
+})
+
+describe('ActivitySpinner', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ stubMatchMedia(false)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ })
+
+ it('renders eight dots with the first frame lit and hidden from assistive technology', () => {
+ const { container } = render(
)
+
+ expect(circles(container)).toHaveLength(8)
+ expect(litIndexes(container)).toEqual(litIndexesFor(SPINNER_FRAMES[0]))
+ expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
+ })
+
+ it('advances one frame every 80ms and wraps after the last frame', () => {
+ const { container } = render(
)
+
+ expect(litIndexes(container)).toEqual(litIndexesFor(SPINNER_FRAMES[0]))
+
+ advanceFrames(1)
+ expect(litIndexes(container)).toEqual(litIndexesFor(SPINNER_FRAMES[1]))
+
+ advanceFrames(1)
+ expect(litIndexes(container)).toEqual(litIndexesFor(SPINNER_FRAMES[2]))
+
+ advanceFrames(SPINNER_FRAMES.length - 2)
+ expect(litIndexes(container)).toEqual(litIndexesFor(SPINNER_FRAMES[0]))
+ })
+
+ it('drives every mounted spinner from a single shared ticker', () => {
+ const { container } = render(
+ <>
+
+
+ >,
+ )
+
+ expect(vi.getTimerCount()).toBe(1)
+
+ const [first, second] = Array.from(container.querySelectorAll('svg'))
+ expect(pattern(first)).toEqual(pattern(second))
+
+ advanceFrames(1)
+
+ expect(litIndexes(first)).toEqual(litIndexesFor(SPINNER_FRAMES[1]))
+ expect(pattern(first)).toEqual(pattern(second))
+ })
+
+ it('clears the shared ticker when the last spinner unmounts', () => {
+ const { unmount } = render(
+ <>
+
+
+ >,
+ )
+
+ expect(vi.getTimerCount()).toBe(1)
+
+ unmount()
+
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('keeps animating when the user prefers reduced motion', () => {
+ stubMatchMedia(true)
+
+ const { container } = render(
)
+
+ expect(vi.getTimerCount()).toBe(1)
+
+ advanceFrames(1)
+
+ expect(litIndexes(container)).toEqual(litIndexesFor(SPINNER_FRAMES[1]))
+ })
+
+ it('keeps every dot inside the cell and clear of its neighbours', () => {
+ const { container } = render(
)
+
+ const svg = container.querySelector('svg')
+ expect(svg).toHaveAttribute('viewBox', '0 0 9 16')
+
+ const dots = circles(container).map((circle) => ({
+ cx: Number(circle.getAttribute('cx')),
+ cy: Number(circle.getAttribute('cy')),
+ r: Number(circle.getAttribute('r')),
+ }))
+
+ expect(dots).toHaveLength(8)
+
+ for (const dot of dots) {
+ expect(dot.cx - dot.r).toBeGreaterThan(0)
+ expect(dot.cx + dot.r).toBeLessThan(9)
+ expect(dot.cy - dot.r).toBeGreaterThan(0)
+ expect(dot.cy + dot.r).toBeLessThan(16)
+ }
+
+ for (let i = 0; i < dots.length; i += 1) {
+ for (let j = i + 1; j < dots.length; j += 1) {
+ const distance = Math.hypot(dots[i].cx - dots[j].cx, dots[i].cy - dots[j].cy)
+ expect(distance).toBeGreaterThanOrEqual(dots[i].r + dots[j].r)
+ }
+ }
+ })
+})
diff --git a/frontend/src/components/ui/activity-spinner.tsx b/frontend/src/components/ui/activity-spinner.tsx
new file mode 100644
index 000000000..1ee8d71fb
--- /dev/null
+++ b/frontend/src/components/ui/activity-spinner.tsx
@@ -0,0 +1,96 @@
+import { useSyncExternalStore } from 'react'
+import { cn } from '@/lib/utils'
+
+export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
+
+const FRAME_INTERVAL_MS = 80
+
+const BRAILLE_BASE = 0x2800
+const DOT_GRID: ReadonlyArray
= [
+ [0, 0],
+ [0, 1],
+ [0, 2],
+ [1, 0],
+ [1, 1],
+ [1, 2],
+ [0, 3],
+ [1, 3],
+]
+const COLUMN_X = [2.25, 6.75] as const
+const ROW_Y = [2.25, 6, 9.75, 13.5] as const
+const DOT_RADIUS = 1.6
+const UNLIT_DOT_OPACITY = 0.25
+
+let frameIndex = 0
+const listeners = new Set<() => void>()
+let ticker: ReturnType | null = null
+
+function emit() {
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener)
+ if (ticker === null) {
+ ticker = setInterval(() => {
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length
+ emit()
+ }, FRAME_INTERVAL_MS)
+ }
+
+ return () => {
+ listeners.delete(listener)
+ if (listeners.size === 0 && ticker !== null) {
+ clearInterval(ticker)
+ ticker = null
+ frameIndex = 0
+ }
+ }
+}
+
+function getSnapshot() {
+ return frameIndex
+}
+
+/**
+ * Decodes one braille frame into its eight dot positions, ordered by braille dot numbering:
+ * dot1, dot2 and dot3 are the left column top-to-bottom, dot4, dot5 and dot6 the right column
+ * top-to-bottom, and dot7 and dot8 the bottom-left and bottom-right cells.
+ */
+export function brailleDots(frame: string): boolean[] {
+ const bits = (frame.codePointAt(0) ?? BRAILLE_BASE) - BRAILLE_BASE
+ return DOT_GRID.map((_, index) => (bits & (1 << index)) !== 0)
+}
+
+/**
+ * Braille spinner mirroring the OpenCode TUI activity spinner as an inline SVG cell so the dots
+ * stay visible at small text sizes. Every mounted instance shares one module-level 80 ms ticker,
+ * which starts with the first subscriber and stops with the last. It animates regardless of
+ * `prefers-reduced-motion`: a still frame is indistinguishable from a stalled spinner, and the
+ * motion is a small in-place status cue like the platform activity indicators.
+ */
+export function ActivitySpinner({ className }: { className?: string }) {
+ const frame = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+ const dots = brailleDots(SPINNER_FRAMES[frame])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/session-status-indicator.test.tsx b/frontend/src/components/ui/session-status-indicator.test.tsx
index e8dd9ef21..b506b2853 100644
--- a/frontend/src/components/ui/session-status-indicator.test.tsx
+++ b/frontend/src/components/ui/session-status-indicator.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { SessionStatusIndicator } from './session-status-indicator'
+import { SPINNER_FRAMES, brailleDots } from './activity-spinner'
import { useSessionStatus } from '@/stores/sessionStatusStore'
const resetStore = () => {
@@ -11,6 +12,7 @@ const resetStore = () => {
knownSessions: new Set(),
outcomes: new Map(),
parents: new Map(),
+ directories: new Map(),
runningShells: new Map(),
revision: 0,
})
@@ -34,4 +36,14 @@ describe('SessionStatusIndicator background activity', () => {
expect(container).toBeEmptyDOMElement()
})
+
+ it('renders the braille spinner with a running label for a busy session', () => {
+ useSessionStatus.getState().setStatus('session-a', { type: 'busy' })
+
+ const { container } = render()
+
+ expect(screen.getByLabelText('Running')).toBeInTheDocument()
+ const litDots = container.querySelectorAll('svg[aria-hidden="true"] circle[fill-opacity="1"]')
+ expect(litDots).toHaveLength(brailleDots(SPINNER_FRAMES[0]).filter(Boolean).length)
+ })
})
diff --git a/frontend/src/components/ui/session-status-indicator.tsx b/frontend/src/components/ui/session-status-indicator.tsx
index faf667e58..fc6c3960e 100644
--- a/frontend/src/components/ui/session-status-indicator.tsx
+++ b/frontend/src/components/ui/session-status-indicator.tsx
@@ -1,4 +1,5 @@
import { memo, useEffect, useState } from 'react'
+import { ActivitySpinner } from '@/components/ui/activity-spinner'
import { useHasBackgroundActivity, useSessionStatusForSession, type SessionStatusType } from '@/stores/sessionStatusStore'
interface SessionStatusIndicatorProps {
@@ -11,6 +12,10 @@ interface SessionStatusIndicatorProps {
const SCANNER_WIDTH = 6
const SCANNER_SEGMENTS = 12
+/**
+ * Shows a session's activity. `sm` renders the braille spinner sized for list rows;
+ * `md` renders the animated scanner used by the session composer.
+ */
export const SessionStatusIndicator = memo(function SessionStatusIndicator({
sessionID,
className = '',
@@ -25,6 +30,9 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({
const backgroundOnly = status.type === 'idle' && hasBackground
useEffect(() => {
+ if (size !== 'md') {
+ return
+ }
if (status.type === 'idle' && !hasBackground) {
return
}
@@ -45,7 +53,7 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({
}, 60)
return () => clearInterval(interval)
- }, [status.type, hasBackground, direction])
+ }, [size, status.type, hasBackground, direction])
useEffect(() => {
if (!showLabel || status.type !== 'retry') {
@@ -68,6 +76,34 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({
return null
}
+ if (size === 'sm') {
+ const colorClass = backgroundOnly
+ ? 'text-muted-foreground'
+ : status.type === 'retry'
+ ? 'text-warning'
+ : status.type === 'compact'
+ ? 'text-primary'
+ : 'text-info'
+ const label = backgroundOnly
+ ? 'Background tasks running'
+ : status.type === 'retry'
+ ? 'Retrying'
+ : status.type === 'compact'
+ ? 'Compacting'
+ : 'Running'
+
+ return (
+
+
+
+ )
+ }
+
const getSegmentColor = (index: number, statusType: SessionStatusType['type'], background: boolean) => {
const distance = Math.abs(index - (position + SCANNER_WIDTH / 2))
const maxDistance = SCANNER_WIDTH / 2
@@ -105,7 +141,7 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({
return 'bg-info/20'
}
- const segmentClass = size === 'sm' ? 'w-0.5 h-2' : 'w-1 h-4'
+ const segmentClass = 'w-1 h-4'
const containerGap = showLabel ? 'gap-2' : ''
return (
diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx
index 13f27e920..c494864b5 100644
--- a/frontend/src/contexts/EventContext.test.tsx
+++ b/frontend/src/contexts/EventContext.test.tsx
@@ -1447,8 +1447,8 @@ describe('EventProvider permissions and forms', () => {
it('resolves ancestry when a permission arrives for an unknown session', async () => {
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' })
+ if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1', location: { directory: '/repo' } })
+ if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1', location: { directory: '/repo' } })
return Promise.reject(new Error('Session not found'))
})
@@ -1475,8 +1475,8 @@ describe('EventProvider permissions and forms', () => {
it('resolves ancestry when a form arrives for an unknown session', async () => {
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' })
+ if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1', location: { directory: '/repo' } })
+ if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1', location: { directory: '/repo' } })
return Promise.reject(new Error('Session not found'))
})
@@ -2269,8 +2269,8 @@ describe('EventProvider permissions and forms', () => {
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' })
+ if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1', location: { directory: '/repo' } })
+ if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1', location: { directory: '/repo' } })
return Promise.reject(new Error('Session not found'))
})
@@ -2394,8 +2394,8 @@ describe('EventProvider permissions and forms', () => {
{ 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' })
+ if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1', location: { directory: '/repo' } })
+ if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1', location: { directory: '/repo' } })
return Promise.reject(new Error('Session not found'))
})
@@ -2421,8 +2421,8 @@ describe('EventProvider permissions and forms', () => {
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' })
+ if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1', parentID: 'grandparent-1', location: { directory: '/repo' } })
+ if (sessionID === 'grandparent-1') return Promise.resolve({ id: 'grandparent-1', location: { directory: '/repo' } })
return Promise.reject(new Error('Session not found'))
})
diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx
index 10bd23f4d..02b3a48f2 100644
--- a/frontend/src/contexts/EventContext.tsx
+++ b/frontend/src/contexts/EventContext.tsx
@@ -20,6 +20,7 @@ import { showToast } from '@/lib/toast'
import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode-event-stream'
import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState'
import { getSessionPath, parseRepoRoute } from '@/lib/navigation'
+import { repoMatchesDirectory } from '@/components/navigation/sidebar-session-tree'
import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough'
import { busyStatusesFromActiveSessions, useSessionStatus } from '@/stores/sessionStatusStore'
import {
@@ -46,6 +47,20 @@ type StreamEvent = (V2Event & { directory?: string }) | { type: 'ssh.host-key-re
type SessionScopedItem = { id: string; sessionID: string }
+const EMPTY_SESSION_IDS: string[] = []
+
+/**
+ * Session ids whose queued item list is non-empty. The array reference is stable across renders
+ * while the set of ids is unchanged, even when the backing record is replaced.
+ */
+function usePendingSessionIDs(bySession: Record): string[] {
+ const idsKey = useMemo(
+ () => Object.keys(bySession).filter((sessionID) => (bySession[sessionID]?.length ?? 0) > 0).join('\u0000'),
+ [bySession],
+ )
+ return useMemo(() => (idsKey ? idsKey.split('\u0000') : EMPTY_SESSION_IDS), [idsKey])
+}
+
function groupBySession(items: T[]): Record {
return items.reduce>((grouped, item) => {
const existing = grouped[item.sessionID] ?? []
@@ -60,18 +75,6 @@ function sortById(items: T[]): T[] {
return [...items].sort((a, b) => a.id.localeCompare(b.id))
}
-function normalizeDirectory(directory?: string | null) {
- return directory?.replace(/\/+$/, '') ?? null
-}
-
-function repoMatchesDirectory(repo: Repo, directory?: string | null) {
- const normalizedDirectory = normalizeDirectory(directory)
- if (!normalizedDirectory) return false
-
- return [repo.fullPath, repo.localPath, repo.sourcePath]
- .some((path) => normalizeDirectory(path) === normalizedDirectory)
-}
-
function isSessionWithinView(
sessionID: string,
viewedSessionID: string,
@@ -152,6 +155,10 @@ interface EventContextValue {
permissions: {
current: PermissionRequest | null
pendingCount: number
+ /**
+ * Ids of sessions with at least one pending permission request, stable while the queue is unchanged.
+ */
+ pendingSessionIDs: string[]
/**
* Session currently being viewed from the route, or null when no session route is active.
*/
@@ -187,6 +194,10 @@ interface EventContextValue {
forms: {
current: FormInfo | null
pendingCount: number
+ /**
+ * Ids of sessions with at least one pending form, stable while the queue is unchanged.
+ */
+ pendingSessionIDs: string[]
/**
* Answers a form and removes it from the queue. Resolves, after showing an "expired" toast,
* when the server no longer knows the form (`FormNotFoundError`); rejects on any other failure.
@@ -279,6 +290,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
const allPermissions = useMemo(() => Object.values(permissionsBySession).flat(), [permissionsBySession])
const allForms = useMemo(() => Object.values(formsBySession).flat(), [formsBySession])
+ const pendingPermissionSessionIDs = usePendingSessionIDs(permissionsBySession)
+ const pendingFormSessionIDs = usePendingSessionIDs(formsBySession)
+
const viewedSessionId = useMemo(
() => parseRepoRoute(location.pathname).sessionId,
[location.pathname],
@@ -532,6 +546,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
try {
const session = await getSession(lookupID)
useSessionStatus.getState().setSessionParent(lookupID, session.parentID ?? null)
+ useSessionStatus.getState().setSessionDirectory(lookupID, session.location.directory)
currentID = session.parentID ?? null
} catch (error) {
if (import.meta.env.DEV) {
@@ -644,6 +659,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
case 'permission.asked': {
const request = event.data
rememberSessionDirectory(request.sessionID, event.directory)
+ if (event.directory) {
+ useSessionStatus.getState().setSessionDirectory(request.sessionID, event.directory)
+ }
addPermission(request)
if (!useSessionStatus.getState().parents.has(request.sessionID)) {
void resolveSessionAncestry([request.sessionID])
@@ -658,6 +676,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
case 'form.created': {
const form = event.data.form
rememberSessionDirectory(form.sessionID, event.directory)
+ if (event.directory) {
+ useSessionStatus.getState().setSessionDirectory(form.sessionID, event.directory)
+ }
addForm(form)
if (!useSessionStatus.getState().parents.has(form.sessionID)) {
void resolveSessionAncestry([form.sessionID])
@@ -672,6 +693,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
}
case 'session.status': {
const { sessionID, status } = event.data
+ if (event.directory) {
+ useSessionStatus.getState().setSessionDirectory(sessionID, event.directory)
+ }
useSessionStatus.getState().setStatus(sessionID, status)
break
}
@@ -680,6 +704,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
break
}
case 'session.execution.started': {
+ if (event.directory) {
+ useSessionStatus.getState().setSessionDirectory(event.data.sessionID, event.directory)
+ }
useSessionStatus.getState().clearOutcome(event.data.sessionID)
useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'busy' })
break
@@ -732,6 +759,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
) {
const sessionID = info.metadata.sessionID
useSessionStatus.getState().setShellRunning(info.id, sessionID, event.directory)
+ useSessionStatus.getState().setSessionDirectory(sessionID, event.directory)
if (!useSessionStatus.getState().parents.has(sessionID)) {
void resolveSessionAncestry([sessionID])
}
@@ -857,6 +885,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
permissions: {
current: currentPermission,
pendingCount: allPermissions.length,
+ pendingSessionIDs: pendingPermissionSessionIDs,
viewedSessionId,
currentDirectory: currentPermissionDirectory,
currentIsFromDifferentSession,
@@ -872,6 +901,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
forms: {
current: currentForm,
pendingCount: allForms.length,
+ pendingSessionIDs: pendingFormSessionIDs,
reply: replyToForm,
cancel: cancelPendingForm,
dismiss: removeForm,
@@ -887,6 +917,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
respondToSSHHostKey,
currentPermission,
allPermissions.length,
+ pendingPermissionSessionIDs,
viewedSessionId,
currentPermissionDirectory,
currentIsFromDifferentSession,
@@ -899,6 +930,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
syncPermissionsForSession,
currentForm,
allForms.length,
+ pendingFormSessionIDs,
replyToForm,
cancelPendingForm,
removeForm,
diff --git a/frontend/src/hooks/useActiveWork.ts b/frontend/src/hooks/useActiveWork.ts
new file mode 100644
index 000000000..b31190c7d
--- /dev/null
+++ b/frontend/src/hooks/useActiveWork.ts
@@ -0,0 +1,164 @@
+import { useMemo } from 'react'
+import { useQueries } from '@tanstack/react-query'
+import { sessionQueryOptions, useSessionsAcrossDirectories } from '@/hooks/useOpenCode'
+import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups'
+import { useForms, usePermissions } from '@/contexts/EventContext'
+import { useSessionStatus } from '@/stores/sessionStatusStore'
+import { useSettings } from '@/hooks/useSettings'
+import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings'
+import { buildActiveWork, collectActiveRoots, activeRepoState, type ActiveWorkGroups } from '@/lib/activeWork'
+import type { Repo, Session } from '@/api/types'
+
+const ACTIVE_WORK_SESSION_LIMIT = 50
+const MS_PER_MINUTE = 60_000
+
+/**
+ * The active-work time window in milliseconds, derived from the user's configured
+ * `activityWindowMinutes` with the shared default as a fallback.
+ */
+export function useActivityWindowMs(): number {
+ const { preferences } = useSettings()
+ const minutes = preferences?.activityWindowMinutes ?? DEFAULT_ACTIVITY_WINDOW_MINUTES
+ return minutes * MS_PER_MINUTE
+}
+
+const combineSessions = (results: Array<{ data?: Session }>): Session[] =>
+ results.flatMap((result) => (result.data ? [result.data] : []))
+
+/**
+ * Distinct active roots for a badge: needs-input roots and running roots that are not also
+ * pending input. Reads only the session-status store and the pending queues, with no network.
+ */
+export function useActiveWorkCounts(): { needsInput: number; running: number } {
+ const statuses = useSessionStatus((state) => state.statuses)
+ const parents = useSessionStatus((state) => state.parents)
+ const runningShells = useSessionStatus((state) => state.runningShells)
+ const pendingPermissionSessionIDs = usePermissions().pendingSessionIDs
+ const pendingFormSessionIDs = useForms().pendingSessionIDs
+
+ return useMemo(() => {
+ const pendingInputSessionIDs = new Set([
+ ...pendingPermissionSessionIDs,
+ ...pendingFormSessionIDs,
+ ])
+ const roots = collectActiveRoots({ statuses, parents, runningShells, pendingInputSessionIDs })
+
+ let running = 0
+ for (const rootID of roots.running) {
+ if (!roots.needsInput.has(rootID)) running += 1
+ }
+
+ return { needsInput: roots.needsInput.size, running }
+ }, [statuses, parents, runningShells, pendingPermissionSessionIDs, pendingFormSessionIDs])
+}
+
+/**
+ * Whether a repo has any active session, returned as a primitive so rows only re-render when it
+ * changes. Reads only the session-status store and the pending queues, with no network.
+ */
+export function useRepoActivityState(repo: Repo): 'needs-input' | 'running' | null {
+ const pendingPermissionSessionIDs = usePermissions().pendingSessionIDs
+ const pendingFormSessionIDs = useForms().pendingSessionIDs
+ const pendingInputSessionIDs = useMemo(
+ () => [...pendingPermissionSessionIDs, ...pendingFormSessionIDs],
+ [pendingPermissionSessionIDs, pendingFormSessionIDs],
+ )
+
+ return useSessionStatus((state) =>
+ activeRepoState({
+ repo,
+ statuses: state.statuses,
+ parents: state.parents,
+ runningShells: state.runningShells,
+ directories: state.directories,
+ pendingInputSessionIDs,
+ }),
+ )
+}
+
+/**
+ * The full active-work list. Performs network reads, so it must only be used by components that
+ * are mounted while visible (the session picker fetches only while open).
+ */
+export function useActiveWork(windowMs: number): {
+ groups: ActiveWorkGroups
+ isLoading: boolean
+ isError: boolean
+} {
+ const { repos, isLoading: isReposLoading } = useNavigableRepos()
+ const directories = useMemo(() => repos.map((repo) => repo.fullPath), [repos])
+
+ const {
+ data: sessions,
+ isLoading: isSessionsLoading,
+ isError: isSessionsError,
+ } = useSessionsAcrossDirectories(directories, {
+ allDirectories: true,
+ limit: ACTIVE_WORK_SESSION_LIMIT,
+ })
+
+ const statuses = useSessionStatus((state) => state.statuses)
+ const parents = useSessionStatus((state) => state.parents)
+ const runningShells = useSessionStatus((state) => state.runningShells)
+ const outcomes = useSessionStatus((state) => state.outcomes)
+ const pendingPermissionSessionIDs = usePermissions().pendingSessionIDs
+ const pendingFormSessionIDs = useForms().pendingSessionIDs
+
+ const roots = useMemo(
+ () =>
+ collectActiveRoots({
+ statuses,
+ parents,
+ runningShells,
+ pendingInputSessionIDs: [...pendingPermissionSessionIDs, ...pendingFormSessionIDs],
+ }),
+ [statuses, parents, runningShells, pendingPermissionSessionIDs, pendingFormSessionIDs],
+ )
+
+ const listedSessionIDs = useMemo(
+ () => new Set(sessions.map((session) => session.id)),
+ [sessions],
+ )
+
+ const missingRootIDs = useMemo(() => {
+ const ids = new Set()
+ for (const sessionID of roots.needsInput) {
+ if (!listedSessionIDs.has(sessionID)) ids.add(sessionID)
+ }
+ for (const sessionID of roots.running) {
+ if (!listedSessionIDs.has(sessionID)) ids.add(sessionID)
+ }
+ return [...ids]
+ }, [roots, listedSessionIDs])
+
+ const fetchedSessions = useQueries({
+ queries: missingRootIDs.map((sessionID) => sessionQueryOptions(sessionID)),
+ combine: combineSessions,
+ })
+
+ const mergedSessions = useMemo(() => {
+ if (fetchedSessions.length === 0) return sessions
+ const seen = new Set(sessions.map((session) => session.id))
+ const extras = fetchedSessions.filter((session) => !seen.has(session.id))
+ return extras.length === 0 ? sessions : [...sessions, ...extras]
+ }, [sessions, fetchedSessions])
+
+ const groups = useMemo(
+ () =>
+ buildActiveWork({
+ now: Date.now(),
+ windowMs,
+ repos,
+ sessions: mergedSessions,
+ roots,
+ outcomes,
+ }),
+ [windowMs, repos, mergedSessions, roots, outcomes],
+ )
+
+ return {
+ groups,
+ isLoading: isReposLoading || isSessionsLoading,
+ isError: isSessionsError,
+ }
+}
diff --git a/frontend/src/hooks/useToolPanel.test.ts b/frontend/src/hooks/useToolPanel.test.ts
index 24b41de9f..435b0bfa8 100644
--- a/frontend/src/hooks/useToolPanel.test.ts
+++ b/frontend/src/hooks/useToolPanel.test.ts
@@ -1,5 +1,33 @@
import { describe, expect, it } from 'vitest'
-import { toggleToolDialogParams, toggleToolPanelParams } from './useToolPanel'
+import { createElement } from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, useLocation } from 'react-router-dom'
+import { isPanelTool, toggleToolDialogParams, toggleToolPanelParams, useToolPanel } from './useToolPanel'
+
+describe('activity tool', () => {
+ it('is a panel tool', () => {
+ expect(isPanelTool('activity')).toBe(true)
+ })
+
+ it('clears without opening a dialog when the panel is not docked', async () => {
+ function Probe() {
+ useToolPanel(false)
+ const location = useLocation()
+ return createElement('div', { 'data-testid': 'search' }, location.search)
+ }
+
+ render(
+ createElement(
+ MemoryRouter,
+ { initialEntries: ['/repos/1/sessions/abc?panel=activity'] },
+ createElement(Probe),
+ ),
+ )
+
+ await waitFor(() => expect(screen.getByTestId('search').textContent).not.toContain('panel=activity'))
+ expect(screen.getByTestId('search').textContent).not.toContain('dialog=activity')
+ })
+})
describe('toggleToolPanelParams', () => {
it('opens a tool, clears other tools owned params, and preserves unrelated params', () => {
diff --git a/frontend/src/hooks/useToolPanel.ts b/frontend/src/hooks/useToolPanel.ts
index d51987b0e..0e89cdf29 100644
--- a/frontend/src/hooks/useToolPanel.ts
+++ b/frontend/src/hooks/useToolPanel.ts
@@ -4,7 +4,7 @@ import { useUrlParams } from './useUrlParams'
const PANEL_PARAM = 'panel'
-const PANEL_TOOLS = ['sourceControl', 'files', 'terminal', 'walkthrough', 'preview', 'mcp', 'actions', 'skills', 'schedules'] as const
+const PANEL_TOOLS = ['sourceControl', 'files', 'terminal', 'walkthrough', 'preview', 'mcp', 'actions', 'skills', 'schedules', 'activity'] as const
export type PanelTool = (typeof PANEL_TOOLS)[number]
@@ -14,7 +14,7 @@ const TOOL_OWNED_PARAMS: Partial> = {
schedules: ['scheduleTab', 'scheduleDialog', 'jobId', 'runId'],
}
-const PANEL_ONLY_TOOLS: ReadonlySet = new Set(['schedules'])
+const PANEL_ONLY_TOOLS: ReadonlySet = new Set(['schedules', 'activity'])
export function isPanelTool(value: string | null): value is PanelTool {
return value !== null && (PANEL_TOOLS as readonly string[]).includes(value)
diff --git a/frontend/src/lib/activeWork.test.ts b/frontend/src/lib/activeWork.test.ts
new file mode 100644
index 000000000..b0dabc29c
--- /dev/null
+++ b/frontend/src/lib/activeWork.test.ts
@@ -0,0 +1,296 @@
+import { describe, it, expect } from 'vitest'
+import type { Repo, Session } from '@/api/types'
+import type { ChildOutcome } from '@/lib/backgroundWork'
+import type { SessionStatusType } from '@/stores/sessionStatusStore'
+import { buildActiveWork, collectActiveRoots, activeRepoState, rootSessionID, formatActivityWindow } from './activeWork'
+
+const NOW = 1_000_000_000_000
+const WINDOW_MS = 60_000
+
+function createSession(id: string, updated: number, directory: string): Session {
+ return {
+ id,
+ projectID: 'proj-1',
+ title: `Session ${id}`,
+ time: { created: updated - 10000, updated },
+ location: { directory },
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ }
+}
+
+function createRepo(overrides: Partial & { id: number; fullPath: string }): Repo {
+ return {
+ localPath: `repos/${overrides.id}`,
+ defaultBranch: 'main',
+ cloneStatus: 'ready',
+ clonedAt: 0,
+ ...overrides,
+ }
+}
+
+function statuses(entries: Record): Map {
+ return new Map(Object.entries(entries))
+}
+
+const repoA = createRepo({ id: 1, fullPath: '/repos/a' })
+const emptyRoots = { needsInput: new Set(), running: new Set() }
+
+describe('formatActivityWindow', () => {
+ it('labels the known minute options', () => {
+ expect(formatActivityWindow(15)).toBe('15 minutes')
+ expect(formatActivityWindow(30)).toBe('30 minutes')
+ expect(formatActivityWindow(60)).toBe('1 hour')
+ expect(formatActivityWindow(120)).toBe('2 hours')
+ expect(formatActivityWindow(240)).toBe('4 hours')
+ expect(formatActivityWindow(480)).toBe('8 hours')
+ expect(formatActivityWindow(1440)).toBe('24 hours')
+ })
+
+ it('falls back to minutes for unknown values', () => {
+ expect(formatActivityWindow(45)).toBe('45 minutes')
+ expect(formatActivityWindow(90)).toBe('90 minutes')
+ })
+
+ it('falls back to hours for unknown values divisible by sixty', () => {
+ expect(formatActivityWindow(180)).toBe('3 hours')
+ })
+})
+
+describe('rootSessionID', () => {
+ it('climbs parents to the top-most root', () => {
+ const parents = new Map([['child', 'parent'], ['parent', 'root'], ['root', null]])
+
+ expect(rootSessionID('child', parents)).toBe('root')
+ expect(rootSessionID('parent', parents)).toBe('root')
+ expect(rootSessionID('root', parents)).toBe('root')
+ })
+
+ it('terminates on a parent cycle', () => {
+ const parents = new Map([['a', 'b'], ['b', 'a']])
+
+ expect(typeof rootSessionID('a', parents)).toBe('string')
+ })
+})
+
+describe('collectActiveRoots', () => {
+ it('rolls a busy child up to its root as running', () => {
+ const parents = new Map([['child', 'root'], ['root', null]])
+
+ const roots = collectActiveRoots({
+ statuses: statuses({ child: { type: 'busy' } }),
+ parents,
+ runningShells: new Map(),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(roots.running.has('root')).toBe(true)
+ expect(roots.running.has('child')).toBe(false)
+ expect(roots.needsInput.size).toBe(0)
+ })
+
+ it('rolls a running shell under a child up to the root', () => {
+ const parents = new Map([['child', 'root'], ['root', null]])
+
+ const roots = collectActiveRoots({
+ statuses: statuses({}),
+ parents,
+ runningShells: new Map([['shell-1', { sessionID: 'child' }]]),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(roots.running.has('root')).toBe(true)
+ })
+
+ it('rolls a pending permission up to the root as needs-input', () => {
+ const parents = new Map([['child', 'root'], ['root', null]])
+
+ const roots = collectActiveRoots({
+ statuses: statuses({}),
+ parents,
+ runningShells: new Map(),
+ pendingInputSessionIDs: ['child'],
+ })
+
+ expect(roots.needsInput.has('root')).toBe(true)
+ })
+
+ it('terminates on a parent cycle', () => {
+ const parents = new Map([['a', 'b'], ['b', 'a']])
+
+ const roots = collectActiveRoots({
+ statuses: statuses({ a: { type: 'busy' } }),
+ parents,
+ runningShells: new Map(),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(roots.running.size).toBe(1)
+ })
+})
+
+describe('activeRepoState', () => {
+ const repo = repoA
+ const noShells = new Map()
+
+ it('matches a busy session by its own directory', () => {
+ const state = activeRepoState({
+ repo,
+ statuses: statuses({ child: { type: 'busy' } }),
+ parents: new Map([['child', 'root'], ['root', null]]),
+ runningShells: noShells,
+ directories: new Map([['child', '/repos/a']]),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(state).toBe('running')
+ })
+
+ it('matches a busy session by its root directory', () => {
+ const state = activeRepoState({
+ repo,
+ statuses: statuses({ child: { type: 'busy' } }),
+ parents: new Map([['child', 'root'], ['root', null]]),
+ runningShells: noShells,
+ directories: new Map([['root', '/repos/a']]),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(state).toBe('running')
+ })
+
+ it('matches a running shell by its own directory', () => {
+ const state = activeRepoState({
+ repo,
+ statuses: statuses({}),
+ parents: new Map(),
+ runningShells: new Map([['shell-1', { sessionID: 'orphan', directory: '/repos/a' }]]),
+ directories: new Map(),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(state).toBe('running')
+ })
+
+ it('matches a session directory against the repo local path', () => {
+ const localRepo = createRepo({ id: 7, fullPath: '/repos/full', localPath: '/local/path' })
+ const state = activeRepoState({
+ repo: localRepo,
+ statuses: statuses({ session: { type: 'busy' } }),
+ parents: new Map(),
+ runningShells: noShells,
+ directories: new Map([['session', '/local/path']]),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(state).toBe('running')
+ })
+
+ it('prefers needs-input over running', () => {
+ const state = activeRepoState({
+ repo,
+ statuses: statuses({ session: { type: 'busy' } }),
+ parents: new Map(),
+ runningShells: noShells,
+ directories: new Map([['session', '/repos/a']]),
+ pendingInputSessionIDs: ['session'],
+ })
+
+ expect(state).toBe('needs-input')
+ })
+
+ it('returns null when every matching session is idle', () => {
+ const state = activeRepoState({
+ repo,
+ statuses: statuses({ session: { type: 'idle' } }),
+ parents: new Map(),
+ runningShells: noShells,
+ directories: new Map([['session', '/repos/a']]),
+ pendingInputSessionIDs: [],
+ })
+
+ expect(state).toBeNull()
+ })
+})
+
+describe('buildActiveWork', () => {
+ it('puts a root with both a pending permission and a busy status in needs-input only, once', () => {
+ const parents = new Map()
+ const roots = collectActiveRoots({
+ statuses: statuses({ root: { type: 'busy' } }),
+ parents,
+ runningShells: new Map(),
+ pendingInputSessionIDs: ['root'],
+ })
+ const sessions = [createSession('root', NOW - 1000, '/repos/a')]
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, roots, outcomes: new Map() })
+
+ expect(groups.needsInput.map((item) => item.session.id)).toEqual(['root'])
+ expect(groups.running).toEqual([])
+ const all = [...groups.needsInput, ...groups.running, ...groups.recent]
+ expect(all.filter((item) => item.session.id === 'root')).toHaveLength(1)
+ })
+
+ it('includes a session updated exactly at the window boundary and excludes the one before it', () => {
+ const sessions = [
+ createSession('inside', NOW - WINDOW_MS, '/repos/a'),
+ createSession('outside', NOW - WINDOW_MS - 1, '/repos/a'),
+ ]
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, roots: emptyRoots, outcomes: new Map() })
+
+ expect(groups.recent.map((item) => item.session.id)).toEqual(['inside'])
+ })
+
+ it('excludes archived sessions', () => {
+ const archived = createSession('archived', NOW - 1000, '/repos/a')
+ archived.time.archived = NOW - 500
+ const sessions = [createSession('active', NOW - 1000, '/repos/a'), archived]
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, roots: emptyRoots, outcomes: new Map() })
+
+ expect(groups.recent.map((item) => item.session.id)).toEqual(['active'])
+ })
+
+ it('excludes sessions whose directory matches no repo', () => {
+ const sessions = [createSession('orphan', NOW - 1000, '/repos/other')]
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, roots: emptyRoots, outcomes: new Map() })
+
+ expect(groups.recent).toEqual([])
+ })
+
+ it('resolves a repo by local path and trims trailing slashes', () => {
+ const repo = createRepo({ id: 5, fullPath: '/repos/full', localPath: '/local/path' })
+ const sessions = [createSession('local', NOW - 1000, '/local/path/')]
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repo], sessions, roots: emptyRoots, outcomes: new Map() })
+
+ expect(groups.recent).toHaveLength(1)
+ expect(groups.recent[0].repo.id).toBe(5)
+ expect(groups.recent[0].key).toBe('/local/path/:local')
+ expect(groups.recent[0].path).toBe('/repos/5/sessions/local')
+ })
+
+ it('sorts each group by updated descending', () => {
+ const sessions = [
+ createSession('oldest', NOW - 3000, '/repos/a'),
+ createSession('newest', NOW - 1000, '/repos/a'),
+ createSession('middle', NOW - 2000, '/repos/a'),
+ ]
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, roots: emptyRoots, outcomes: new Map() })
+
+ expect(groups.recent.map((item) => item.session.id)).toEqual(['newest', 'middle', 'oldest'])
+ })
+
+ it('carries the child outcome onto the item', () => {
+ const sessions = [createSession('root', NOW - 1000, '/repos/a')]
+ const outcomes = new Map([['root', 'failed']])
+
+ const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, roots: emptyRoots, outcomes })
+
+ expect(groups.recent[0].outcome).toBe('failed')
+ })
+})
diff --git a/frontend/src/lib/activeWork.ts b/frontend/src/lib/activeWork.ts
new file mode 100644
index 000000000..3dd35ad29
--- /dev/null
+++ b/frontend/src/lib/activeWork.ts
@@ -0,0 +1,191 @@
+import type { Repo, Session } from '@/api/types'
+import type { ChildOutcome } from '@/lib/backgroundWork'
+import type { SessionStatusType } from '@/stores/sessionStatusStore'
+import { getSessionKey } from '@/lib/sessionKey'
+import { getSessionPath } from '@/lib/navigation'
+import { findRepoForDirectory, repoMatchesDirectory } from '@/components/navigation/sidebar-session-tree'
+
+const ACTIVITY_WINDOW_LABELS: Record = {
+ 15: '15 minutes',
+ 30: '30 minutes',
+ 60: '1 hour',
+ 120: '2 hours',
+ 240: '4 hours',
+ 480: '8 hours',
+ 1440: '24 hours',
+}
+
+/**
+ * Human-readable label for an activity window in minutes. Known options map to their labels;
+ * other values fall back to minutes, or to hours when the value divides evenly into hours.
+ */
+export function formatActivityWindow(minutes: number): string {
+ const known = ACTIVITY_WINDOW_LABELS[minutes]
+ if (known) return known
+
+ if (minutes % 60 === 0) {
+ const hours = minutes / 60
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'}`
+ }
+
+ return `${minutes} minutes`
+}
+
+export type ActiveWorkState = 'needs-input' | 'running' | 'recent'
+
+export interface ActiveWorkItem {
+ key: string
+ session: Session
+ repo: Repo
+ state: ActiveWorkState
+ outcome?: ChildOutcome
+ path: string
+}
+
+export interface ActiveWorkGroups {
+ needsInput: ActiveWorkItem[]
+ running: ActiveWorkItem[]
+ recent: ActiveWorkItem[]
+}
+
+export function rootSessionID(
+ sessionID: string,
+ parents: ReadonlyMap,
+): string {
+ const visited = new Set()
+ let currentID = sessionID
+
+ while (!visited.has(currentID)) {
+ visited.add(currentID)
+ const parentID = parents.get(currentID)
+ if (parentID === undefined || parentID === null) break
+ currentID = parentID
+ }
+
+ return currentID
+}
+
+/**
+ * Rolls every active or pending session id up to its root. `needsInput` and `running` may both
+ * contain a root; callers apply the needs-input over running priority when grouping.
+ */
+export function collectActiveRoots(input: {
+ statuses: ReadonlyMap
+ parents: ReadonlyMap
+ runningShells: ReadonlyMap
+ pendingInputSessionIDs: Iterable
+}): { needsInput: Set; running: Set } {
+ const { statuses, parents, runningShells, pendingInputSessionIDs } = input
+
+ const needsInput = new Set()
+ for (const sessionID of pendingInputSessionIDs) {
+ needsInput.add(rootSessionID(sessionID, parents))
+ }
+
+ const running = new Set()
+ for (const [sessionID, status] of statuses) {
+ if (status.type === 'idle') continue
+ running.add(rootSessionID(sessionID, parents))
+ }
+ for (const shell of runningShells.values()) {
+ running.add(rootSessionID(shell.sessionID, parents))
+ }
+
+ return { needsInput, running }
+}
+
+/**
+ * Whether a repo has any active session. A session counts when its own directory or its root's
+ * directory matches the repo; running shells carry their own directory. Needs-input outranks running.
+ */
+export function activeRepoState(input: {
+ repo: Repo
+ statuses: ReadonlyMap
+ parents: ReadonlyMap
+ runningShells: ReadonlyMap
+ directories: ReadonlyMap
+ pendingInputSessionIDs: Iterable
+}): 'needs-input' | 'running' | null {
+ const { repo, statuses, parents, runningShells, directories, pendingInputSessionIDs } = input
+
+ const sessionMatches = (sessionID: string): boolean => {
+ if (repoMatchesDirectory(repo, directories.get(sessionID))) return true
+ const rootID = rootSessionID(sessionID, parents)
+ return rootID !== sessionID && repoMatchesDirectory(repo, directories.get(rootID))
+ }
+
+ for (const sessionID of pendingInputSessionIDs) {
+ if (sessionMatches(sessionID)) return 'needs-input'
+ }
+
+ for (const [sessionID, status] of statuses) {
+ if (status.type === 'idle') continue
+ if (sessionMatches(sessionID)) return 'running'
+ }
+
+ for (const shell of runningShells.values()) {
+ if (repoMatchesDirectory(repo, shell.directory) || sessionMatches(shell.sessionID)) return 'running'
+ }
+
+ return null
+}
+
+/**
+ * Groups the given sessions into needs-input, running, and recent roots. A session appears in
+ * exactly one group (needs-input over running over recent) and only when it resolves to a repo.
+ */
+export function buildActiveWork(input: {
+ now: number
+ windowMs: number
+ repos: Repo[]
+ sessions: Session[]
+ roots: { needsInput: Set; running: Set }
+ outcomes: ReadonlyMap
+}): ActiveWorkGroups {
+ const { now, windowMs, repos, sessions, roots, outcomes } = input
+ const windowStart = now - windowMs
+
+ const needsInput: ActiveWorkItem[] = []
+ const running: ActiveWorkItem[] = []
+ const recent: ActiveWorkItem[] = []
+ const seen = new Set()
+
+ for (const session of sessions) {
+ if (seen.has(session.id)) continue
+ if (session.time.archived) continue
+
+ const state: ActiveWorkState | null = roots.needsInput.has(session.id)
+ ? 'needs-input'
+ : roots.running.has(session.id)
+ ? 'running'
+ : session.time.updated >= windowStart
+ ? 'recent'
+ : null
+ if (!state) continue
+
+ const repo = findRepoForDirectory(repos, session.location.directory)
+ if (!repo) continue
+
+ seen.add(session.id)
+ const item: ActiveWorkItem = {
+ key: getSessionKey(session),
+ session,
+ repo,
+ state,
+ outcome: outcomes.get(session.id),
+ path: getSessionPath(repo.id, session.id),
+ }
+
+ if (state === 'needs-input') needsInput.push(item)
+ else if (state === 'running') running.push(item)
+ else recent.push(item)
+ }
+
+ const byUpdatedDesc = (a: ActiveWorkItem, b: ActiveWorkItem) =>
+ b.session.time.updated - a.session.time.updated
+ needsInput.sort(byUpdatedDesc)
+ running.sort(byUpdatedDesc)
+ recent.sort(byUpdatedDesc)
+
+ return { needsInput, running, recent }
+}
diff --git a/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx b/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx
index b3bedcfc8..b7f9a5d2e 100644
--- a/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx
+++ b/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx
@@ -39,6 +39,11 @@ vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ ResetPermissionsDia
vi.mock('@/components/source-control', () => ({ SourceControlPanel: () => null }))
vi.mock('@/components/notifications/PendingActionsGroup', () => ({ PendingActionsGroup: () => null }))
+vi.mock('@/hooks/useActiveWork', () => ({
+ useActiveWorkCounts: () => ({ needsInput: 0, running: 0 }),
+ useRepoActivityState: () => null,
+}))
+
vi.mock('@/components/terminal/TerminalView', async () => {
const React = await import('react')
return {
diff --git a/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx b/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx
index c9b85578d..abc175fd0 100644
--- a/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx
+++ b/frontend/src/pages/__tests__/RepoDetail.schedule-sessions.test.tsx
@@ -58,6 +58,10 @@ vi.mock('@/hooks/useRepoSiblings', () => ({
}))
vi.mock('@/hooks/useRepoActivity', () => ({ useRepoActivity: mocks.useRepoActivity }))
+vi.mock('@/hooks/useActiveWork', () => ({
+ useActiveWorkCounts: () => ({ needsInput: 0, running: 0 }),
+ useRepoActivityState: () => null,
+}))
vi.mock('@/hooks/useSSE', () => ({ useSSE: mocks.useSSE }))
vi.mock('@/hooks/useOpenCode', () => ({ useCreateSession: mocks.useCreateSession }))
vi.mock('@/hooks/useMultiRuns', () => ({
diff --git a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx
index cfc63b64f..f2b1b3a49 100644
--- a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx
+++ b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx
@@ -42,6 +42,10 @@ vi.mock('@/hooks/useRepoSiblings', () => ({
}))
vi.mock('@/hooks/useRepoActivity', () => ({ useRepoActivity: mocks.useRepoActivity }))
+vi.mock('@/hooks/useActiveWork', () => ({
+ useActiveWorkCounts: () => ({ needsInput: 0, running: 0 }),
+ useRepoActivityState: () => null,
+}))
vi.mock('@/hooks/useSSE', () => ({ useSSE: mocks.useSSE }))
vi.mock('@/hooks/useOpenCode', () => ({ useCreateSession: mocks.useCreateSession }))
diff --git a/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx b/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx
index a5dd60d54..19fe875ea 100644
--- a/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.assistant-loading.test.tsx
@@ -108,6 +108,11 @@ vi.mock('@/contexts/EventContext', async (importOriginal) => {
}
})
+vi.mock('@/hooks/useActiveWork', () => ({
+ useActiveWorkCounts: () => ({ needsInput: 0, running: 0 }),
+ useRepoActivityState: () => null,
+}))
+
vi.mock('@/api/repos', () => ({
getRepo: vi.fn((repoId: number) => Promise.resolve(repoId === 0 ? {
id: 0,
diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx
index c0ade1305..8b2528dbc 100644
--- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx
@@ -135,6 +135,11 @@ vi.mock('@/contexts/EventContext', async (importOriginal) => {
}
})
+vi.mock('@/hooks/useActiveWork', () => ({
+ useActiveWorkCounts: () => ({ needsInput: 0, running: 0 }),
+ useRepoActivityState: () => null,
+}))
+
vi.mock('@/api/repos', () => ({
getRepo: vi.fn(() => Promise.resolve({
id: 1,
diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
index 7362bed91..54458b116 100644
--- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
@@ -130,6 +130,11 @@ vi.mock('@/contexts/EventContext', async (importOriginal) => {
}
})
+vi.mock('@/hooks/useActiveWork', () => ({
+ useActiveWorkCounts: () => ({ needsInput: 0, running: 0 }),
+ useRepoActivityState: () => null,
+}))
+
vi.mock('@/api/repos', () => ({
getRepo: vi.fn(() => Promise.resolve({
id: 1,
diff --git a/frontend/src/stores/sessionStatusStore.ts b/frontend/src/stores/sessionStatusStore.ts
index 7f30d4246..649abfb61 100644
--- a/frontend/src/stores/sessionStatusStore.ts
+++ b/frontend/src/stores/sessionStatusStore.ts
@@ -30,6 +30,7 @@ interface SessionStatusStore {
knownSessions: Set
outcomes: Map
parents: Map
+ directories: Map
runningShells: Map
shellRevisions: Map
shellRevision: number
@@ -45,6 +46,7 @@ interface SessionStatusStore {
getStatus: (sessionID: string) => SessionStatusType
clearStatus: (sessionID: string) => void
setSessionParent: (sessionID: string, parentID: string | null) => void
+ setSessionDirectory: (sessionID: string, directory: string) => void
setShellRunning: (shellID: string, sessionID: string, directory: string) => void
clearShell: (shellID: string) => void
replaceRunningShells: (directory: string, shells: Array<{ id: string; sessionID: string }>, token: ShellSnapshotToken) => void
@@ -245,6 +247,7 @@ export const useSessionStatus = create((set, get) => {
knownSessions: new Set(),
outcomes: new Map(),
parents: new Map(),
+ directories: new Map(),
runningShells: new Map(),
shellRevisions: new Map(),
shellRevision: 0,
@@ -448,6 +451,15 @@ export const useSessionStatus = create((set, get) => {
})
},
+ setSessionDirectory: (sessionID: string, directory: string) => {
+ if (get().directories.get(sessionID) === directory) return
+ set((state) => {
+ const directories = new Map(state.directories)
+ directories.set(sessionID, directory)
+ return { directories }
+ })
+ },
+
setShellRunning: (shellID: string, sessionID: string, directory: string) => {
const current = get().runningShells.get(shellID)
if (current && current.sessionID === sessionID && current.directory === directory) {
@@ -533,6 +545,7 @@ export const useSessionStatus = create((set, get) => {
current.knownSessions.has(sessionID) ||
current.outcomes.has(sessionID) ||
current.parents.has(sessionID) ||
+ current.directories.has(sessionID) ||
ownsShell
if (!hasSession && inFlightSnapshots.size === 0) return
@@ -542,12 +555,14 @@ export const useSessionStatus = create((set, get) => {
const knownSessions = new Set(state.knownSessions)
const outcomes = new Map(state.outcomes)
const parents = new Map(state.parents)
+ const directories = new Map(state.directories)
const runningShells = new Map(state.runningShells)
statuses.delete(sessionID)
statusCache.delete(sessionID)
knownSessions.delete(sessionID)
outcomes.delete(sessionID)
parents.delete(sessionID)
+ directories.delete(sessionID)
const removedShellIDs: string[] = []
for (const [shellID, shell] of runningShells.entries()) {
if (shell.sessionID !== sessionID) continue
@@ -560,6 +575,7 @@ export const useSessionStatus = create((set, get) => {
knownSessions,
outcomes,
parents,
+ directories,
runningShells,
...(shellTouchPatch(state, removedShellIDs) ?? {}),
...(recordRevisionPatch(state, sessionID) ?? {}),
diff --git a/shared/src/schemas/internal-assistant.ts b/shared/src/schemas/internal-assistant.ts
index 7b10b7a91..76a5ba082 100644
--- a/shared/src/schemas/internal-assistant.ts
+++ b/shared/src/schemas/internal-assistant.ts
@@ -91,6 +91,7 @@ export const AssistantSettingsPatchSchema = UserPreferencesSchema.pick({
mode: true,
defaultModel: true,
walkthroughModel: true,
+ activityWindowMinutes: true,
defaultAgent: true,
autoScroll: true,
expandDiffs: true,
diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts
index 48eb2a707..69a92e60a 100644
--- a/shared/src/schemas/settings.ts
+++ b/shared/src/schemas/settings.ts
@@ -66,6 +66,10 @@ export type STTConfig = {
export const DEFAULT_LEADER_KEY = 'Ctrl+X';
+export const DEFAULT_ACTIVITY_WINDOW_MINUTES = 30;
+
+export const ACTIVITY_WINDOW_MINUTE_OPTIONS = [15, 30, 60, 120, 240, 480, 1440] as const;
+
export const DEFAULT_KEYBOARD_SHORTCUTS = {
submit: 'Cmd+Enter',
abort: 'Escape',
@@ -192,6 +196,7 @@ export const UserPreferencesSchema = z.object({
mode: z.enum(["plan", "build"]),
defaultModel: z.string().optional(),
walkthroughModel: z.string().optional(),
+ activityWindowMinutes: z.number().int().min(5).max(1440).optional(),
defaultAgent: z.string().optional(),
autoScroll: z.boolean(),
expandDiffs: z.boolean(),
@@ -249,6 +254,7 @@ export const DEFAULT_USER_PREFERENCES = {
theme: "dark" as const,
colorTheme: MANAGER_COLOR_THEME_ID,
mode: "build" as const,
+ activityWindowMinutes: DEFAULT_ACTIVITY_WINDOW_MINUTES,
autoScroll: true,
expandDiffs: true,
expandToolCalls: false,
From 8b6251d83b49b4e60604a28a63412c787bfcff60 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Fri, 16 Oct 2026 11:42:03 -0400
Subject: [PATCH 2/4] fix(activity): animate the braille spinner in SVG and
tighten the activity lists
- Draw the TUI braille spinner as a 2x4 SVG dot cell stepped by declarative
timelines derived from SPINNER_FRAMES: no JavaScript timer or React
render per frame, and it keeps animating under prefers-reduced-motion so an
active session never looks stalled
- Enlarge the spinner cell and dots so it stays legible next to 14px text
- Add a compact ActivityList variant for the mobile More drawer and Repos sheet:
one flat list capped at five rows, single-line rows, no group subheadings
- Let ActivityList callers own navigation so a row tap navigates once instead of
pushing a duplicate history entry
- Share the picker keyboard navigation between the session and project tabs and
reuse the shared session merge helpers in the sidebar repo groups
---
.../components/activity/ActivityList.test.tsx | 19 +-
.../src/components/activity/ActivityList.tsx | 51 +-
.../activity/CompactActivityList.tsx | 6 +-
.../components/navigation/MoreDrawer.test.tsx | 6 +-
.../navigation/RepoQuickSwitchSheet.test.tsx | 6 +-
.../navigation/ToolSidePanel.test.tsx | 8 +-
.../components/navigation/ToolSidePanel.tsx | 54 +-
.../navigation/sidebar-session-tree.ts | 19 +-
.../session/ProjectPickerContent.tsx | 160 ++----
.../session/SessionPickerDialog.test.tsx | 47 +-
.../session/SessionPickerDialog.tsx | 531 ++++++------------
.../session/usePickerKeyboardNav.tsx | 209 +++++++
.../settings/ActivitySettings.test.tsx | 7 +
.../components/settings/ActivitySettings.tsx | 12 +-
.../components/ui/activity-spinner.test.tsx | 80 +--
.../src/components/ui/activity-spinner.tsx | 62 +-
frontend/src/contexts/EventContext.test.tsx | 14 +-
frontend/src/contexts/EventContext.tsx | 19 +-
frontend/src/hooks/useActiveWork.ts | 100 ++--
frontend/src/hooks/useSSE.test.tsx | 4 +-
frontend/src/hooks/useSidebarRepoGroups.ts | 23 +-
frontend/src/lib/activeWork.test.ts | 70 ++-
frontend/src/lib/activeWork.ts | 117 ++--
.../src/stores/sessionStatusStore.test.ts | 1 +
24 files changed, 848 insertions(+), 777 deletions(-)
create mode 100644 frontend/src/components/session/usePickerKeyboardNav.tsx
diff --git a/frontend/src/components/activity/ActivityList.test.tsx b/frontend/src/components/activity/ActivityList.test.tsx
index a70bf87aa..53e88e4c8 100644
--- a/frontend/src/components/activity/ActivityList.test.tsx
+++ b/frontend/src/components/activity/ActivityList.test.tsx
@@ -14,7 +14,7 @@ vi.mock('@/hooks/useActiveWork', () => ({
useActiveWork: mocks.useActiveWork,
}))
-const WINDOW_MS = 30 * 60_000
+const WINDOW_MINUTES = 30
const NOW = Date.now()
function createSession(id: string, title: string, updated = NOW - 60_000): Session {
@@ -71,11 +71,16 @@ function setGroups(groups: {
const renderList = (
onNavigate = vi.fn(),
path = '/',
- props: { hideWhenEmpty?: boolean; title?: string; variant?: 'full' | 'compact' } = {},
+ props: {
+ hideWhenEmpty?: boolean
+ title?: string
+ variant?: 'full' | 'compact'
+ minutes?: number
+ } = {},
) =>
render(
-
+
,
)
@@ -118,6 +123,14 @@ describe('ActivityList', () => {
expect(screen.getByText('No active or recent sessions in the last 30 minutes')).toBeInTheDocument()
})
+ it('renders the empty state with an hour-scale window', () => {
+ setGroups({})
+
+ renderList(vi.fn(), '/', { minutes: 1440 })
+
+ expect(screen.getByText('No active or recent sessions in the last 24 hours')).toBeInTheDocument()
+ })
+
it('calls onNavigate with the item when a row is clicked', () => {
const onNavigate = vi.fn()
const item = createItem({ state: 'recent', session: createSession('c1', 'Recent'), repo: createRepo(3, 'Gamma') })
diff --git a/frontend/src/components/activity/ActivityList.tsx b/frontend/src/components/activity/ActivityList.tsx
index 916ac61a9..620ee3eff 100644
--- a/frontend/src/components/activity/ActivityList.tsx
+++ b/frontend/src/components/activity/ActivityList.tsx
@@ -4,17 +4,22 @@ import { useActiveWork } from '@/hooks/useActiveWork'
import { SessionStatusIndicator } from '@/components/ui/session-status-indicator'
import { parseRepoRoute } from '@/lib/navigation'
import { cn, formatShortRelativeTime, getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils'
-import type { ActiveWorkItem } from '@/lib/activeWork'
+import {
+ ACTIVE_WORK_GROUPS,
+ MS_PER_MINUTE,
+ formatActivityEmptyMessage,
+ type ActiveWorkItem,
+} from '@/lib/activeWork'
const UNTITLED_SESSION_TITLE = 'Untitled Session'
-const MS_PER_MINUTE = 60_000
const COMPACT_MAX_ITEMS = 5
const FULL_ICON_CLASS = 'h-4 w-4'
const COMPACT_ICON_CLASS = 'h-3.5 w-3.5'
interface ActivityListProps {
- windowMs: number
+ /** Activity window in minutes. */
+ minutes: number
/** Called when a row is chosen; the caller owns navigation so each surface can apply its own history and close rules. */
onNavigate: (item: ActiveWorkItem) => void
hideWhenEmpty?: boolean
@@ -137,14 +142,14 @@ function ActivityGroup({ heading, items, currentSessionID, currentRepoId, onSele
}
export function ActivityList({
- windowMs,
+ minutes,
onNavigate,
hideWhenEmpty = false,
title,
variant = 'full',
}: ActivityListProps) {
const location = useLocation()
- const { groups, isLoading, isError } = useActiveWork(windowMs)
+ const { groups, isLoading, isError } = useActiveWork(minutes * MS_PER_MINUTE)
const route = parseRepoRoute(location.pathname)
@@ -163,11 +168,8 @@ export function ActivityList({
if (isEmpty) {
if (hideWhenEmpty) return null
- const minutes = Math.round(windowMs / MS_PER_MINUTE)
return (
-
- No active or recent sessions in the last {minutes} minutes
-
+ {formatActivityEmptyMessage(minutes)}
)
}
@@ -203,27 +205,16 @@ export function ActivityList({
))
) : (
<>
-
-
-
+ {ACTIVE_WORK_GROUPS.map((group) => (
+
+ ))}
>
)}
diff --git a/frontend/src/components/activity/CompactActivityList.tsx b/frontend/src/components/activity/CompactActivityList.tsx
index 8fc880b07..d45c2baaa 100644
--- a/frontend/src/components/activity/CompactActivityList.tsx
+++ b/frontend/src/components/activity/CompactActivityList.tsx
@@ -1,4 +1,4 @@
-import { useActivityWindowMs } from '@/hooks/useActiveWork'
+import { useActivityWindowMinutes } from '@/hooks/useActiveWork'
import { ActivityList } from '@/components/activity/ActivityList'
/**
@@ -6,11 +6,11 @@ import { ActivityList } from '@/components/activity/ActivityList'
* Reads the network through `useActiveWork`, so it must only be mounted while visible.
*/
export function CompactActivityList({ onNavigate }: { onNavigate: (path: string) => void }) {
- const windowMs = useActivityWindowMs()
+ const minutes = useActivityWindowMinutes()
return (
{
const activityMocks = vi.hoisted(() => ({
useActiveWork: vi.fn(),
- useActivityWindowMs: vi.fn(),
+ useActivityWindowMinutes: vi.fn(),
}))
vi.mock('@/hooks/useActiveWork', () => ({
useActiveWork: activityMocks.useActiveWork,
- useActivityWindowMs: activityMocks.useActivityWindowMs,
+ useActivityWindowMinutes: activityMocks.useActivityWindowMinutes,
useRepoActivityState: () => null,
}))
@@ -155,7 +155,7 @@ describe('MoreDrawer', () => {
cloneStatus: 'ready',
clonedAt: 0,
})
- activityMocks.useActivityWindowMs.mockReturnValue(30 * 60_000)
+ activityMocks.useActivityWindowMinutes.mockReturnValue(30)
setActivityGroups({})
})
diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx
index 0df7afa90..159d41e16 100644
--- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx
+++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx
@@ -35,12 +35,12 @@ const { sessionsData, sessionsHookCalls, permissionSessions, createSessionDirect
const activityMocks = vi.hoisted(() => ({
useActiveWork: vi.fn(),
- useActivityWindowMs: vi.fn(),
+ useActivityWindowMinutes: vi.fn(),
}))
vi.mock('@/hooks/useActiveWork', () => ({
useActiveWork: activityMocks.useActiveWork,
- useActivityWindowMs: activityMocks.useActivityWindowMs,
+ useActivityWindowMinutes: activityMocks.useActivityWindowMinutes,
useRepoActivityState: () => null,
}))
@@ -168,7 +168,7 @@ describe('RepoQuickSwitchSheet', () => {
sessionsData.splice(0, sessionsData.length)
sessionsHookCalls.splice(0, sessionsHookCalls.length)
permissionSessions.current.clear()
- activityMocks.useActivityWindowMs.mockReturnValue(30 * 60_000)
+ activityMocks.useActivityWindowMinutes.mockReturnValue(30)
setActivityGroups({})
})
diff --git a/frontend/src/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx
index 3ff36cf4e..c174ea344 100644
--- a/frontend/src/components/navigation/ToolSidePanel.test.tsx
+++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx
@@ -12,13 +12,13 @@ import { useToolPanel } from '@/hooks/useToolPanel'
const activityMocks = vi.hoisted(() => ({
useActiveWork: vi.fn(),
useActiveWorkCounts: vi.fn(),
- useActivityWindowMs: vi.fn(),
+ useActivityWindowMinutes: vi.fn(),
}))
vi.mock('@/hooks/useActiveWork', () => ({
useActiveWork: activityMocks.useActiveWork,
useActiveWorkCounts: activityMocks.useActiveWorkCounts,
- useActivityWindowMs: activityMocks.useActivityWindowMs,
+ useActivityWindowMinutes: activityMocks.useActivityWindowMinutes,
}))
vi.mock('@/components/file-browser/FileBrowser', () => ({
@@ -91,7 +91,7 @@ function search(): URLSearchParams {
return new URLSearchParams(screen.getByTestId('search').textContent ?? '')
}
-const WINDOW_MS = 30 * 60_000
+const WINDOW_MINUTES = 30
const NOW = Date.now()
function createSession(id: string, title: string, updated = NOW - 60_000): Session {
@@ -152,7 +152,7 @@ function HomeHarness() {
beforeEach(() => {
vi.clearAllMocks()
activityMocks.useActiveWorkCounts.mockReturnValue({ needsInput: 0, running: 0 })
- activityMocks.useActivityWindowMs.mockReturnValue(WINDOW_MS)
+ activityMocks.useActivityWindowMinutes.mockReturnValue(WINDOW_MINUTES)
setActivityGroups()
})
diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx
index aaa1303ec..85847eaff 100644
--- a/frontend/src/components/navigation/ToolSidePanel.tsx
+++ b/frontend/src/components/navigation/ToolSidePanel.tsx
@@ -26,41 +26,56 @@ import { isPanelTool, type ToolPanelState, type PanelTool } from '@/hooks/useToo
import { useOpenNavItem } from '@/hooks/useOpenNavItem'
import { ActivityList } from '@/components/activity/ActivityList'
import { ActivityCountPill } from '@/components/activity/ActivityCountPill'
-import { useActiveWorkCounts, useActivityWindowMs } from '@/hooks/useActiveWork'
+import { useActiveWorkCounts, useActivityWindowMinutes } from '@/hooks/useActiveWork'
import { formatActivityWindow } from '@/lib/activeWork'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { restoreOverlayFocus } from '@/lib/overlayFocus'
import { cn } from '@/lib/utils'
-const MS_PER_MINUTE = 60_000
-
const PANEL_WIDTH_CLASSES: Partial> = {
activity: 'w-80',
}
function ActivityWindowLabel() {
- const windowMs = useActivityWindowMs()
+ const minutes = useActivityWindowMinutes()
return (
- Last {formatActivityWindow(Math.round(windowMs / MS_PER_MINUTE))}
+ Last {formatActivityWindow(minutes)}
)
}
function ActivityPanelBody() {
const navigate = useNavigate()
- const windowMs = useActivityWindowMs()
+ const minutes = useActivityWindowMinutes()
return (
navigate({ pathname: item.path, search: '?panel=activity' })}
/>
)
}
+function ActivityRailIndicator() {
+ const { needsInput, running } = useActiveWorkCounts()
+ const total = needsInput + running
+ const label = total > 0 ? `Activity, ${needsInput} needs input, ${running} running` : 'Activity'
+
+ return (
+ <>
+ {label}
+
+ >
+ )
+}
+
interface ToolSidePanelProps {
panel: ToolPanelState
filesBasePath: string | undefined
@@ -81,11 +96,14 @@ interface RailButtonProps {
item: MoreDrawerItem
active: boolean
onClick: () => void
- ariaLabel?: string
+ /**
+ * Rendered inside the button. When present, the indicator owns the button's accessible name,
+ * so the item label is not applied as an `aria-label`.
+ */
indicator?: ReactNode
}
-function RailButton({ item, active, onClick, ariaLabel, indicator }: RailButtonProps) {
+function RailButton({ item, active, onClick, indicator }: RailButtonProps) {
const Icon = item.icon
return (
@@ -93,7 +111,7 @@ function RailButton({ item, active, onClick, ariaLabel, indicator }: RailButtonP