Skip to content
141 changes: 141 additions & 0 deletions e2e/background-activity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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<ReturnType<APIRequestContext['post']>>, label: string): Promise<unknown> {
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<string> {
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<string> {
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<void> {
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)
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', () => {
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)
})
})
27 changes: 27 additions & 0 deletions frontend/src/components/message/PromptInput.command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down Expand Up @@ -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<typeof import('@/stores/sessionStatusStore')>()
return { ...actual, useHasBackgroundActivity: mocks.useHasBackgroundActivity }
})
vi.mock('@/lib/toast', () => ({ showToast: mocks.showToast }))

vi.mock('@/contexts/EventContext', () => ({
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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 }) => (
<QueryClientProvider client={queryClient}>
<PromptInput {...defaultProps} {...props} />
</QueryClientProvider>
)

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,
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -248,6 +249,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
}, [])

const sessionAgent = useSessionAgent(sessionID, directory)
const hasBackgroundActivity = useHasBackgroundActivity(sessionID)
const currentMode = localMode ?? sessionAgent.agent
const setStoredAgent = useSessionAgentStore((s) => s.setAgent)
const sendPrompt = useSendPrompt(directory)
Expand Down Expand Up @@ -1471,8 +1473,17 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)



const sessionActivityAnnouncement = isSessionActive
? 'Session working'
: hasBackgroundActivity
? 'Background tasks running'
: ''

return (
<div className={`relative isolate border border-border dark:border-border/30 rounded-xl p-2 md:p-3 mb-4 md:mb-1 w-full before:pointer-events-none before:absolute before:-inset-px before:-z-10 before:rounded-xl before:bg-background/95 before:backdrop-blur-md before:content-[''] ${hasPendingPermissionForSession ? 'border-highlight/50 ring-1 ring-highlight/30' : composerModeStyle?.frame ?? ''} ${isTakeoverOpen ? 'z-[60]' : ''}`}>
<div role="status" aria-live="polite" className="sr-only">
{sessionActivityAnnouncement}
</div>
{composerModeStyle && (
<span
data-testid="composer-mode-badge"
Expand Down Expand Up @@ -1540,7 +1551,7 @@ return (
<ArrowDown className="w-4 h-4" />
<span>Latest</span>
</button>
{isSessionActive && (
{(isSessionActive || hasBackgroundActivity) && (
<div className="px-2.5 py-1.5 rounded-lg text-xs font-medium text-muted-foreground max-w-[120px]">
<SessionStatusIndicator sessionID={sessionID} showLabel />
</div>
Expand Down Expand Up @@ -1573,7 +1584,7 @@ return (
</IconToggleButton>
</>
)}
{isSessionActive && (
{(isSessionActive || hasBackgroundActivity) && (
<div className="px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs md:text-sm font-medium text-muted-foreground max-w-[120px] md:max-w-[180px]">
<SessionStatusIndicator sessionID={sessionID} showLabel />
</div>
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/components/ui/session-status-indicator.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SessionStatusIndicator sessionID="session-a" showLabel />)

expect(screen.getByLabelText('Background tasks running')).toBeInTheDocument()
})

it('renders nothing when idle without background activity', () => {
const { container } = render(<SessionStatusIndicator sessionID="session-a" showLabel />)

expect(container).toBeEmptyDOMElement()
})
})
27 changes: 20 additions & 7 deletions frontend/src/components/ui/session-status-indicator.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}

Expand All @@ -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') {
Expand All @@ -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

Expand All @@ -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'
Expand All @@ -100,12 +109,16 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({
const containerGap = showLabel ? 'gap-2' : ''

return (
<div className={`flex items-center ${containerGap} ${className}`}>
<div
className={`flex items-center ${containerGap} ${className}`}
title={backgroundOnly ? 'Background tasks running' : undefined}
aria-label={backgroundOnly ? 'Background tasks running' : undefined}
Comment thread
chriswritescode-dev marked this conversation as resolved.
>
<div className="flex gap-0.5">
{Array.from({ length: SCANNER_SEGMENTS }).map((_, i) => (
<div
key={i}
className={`${segmentClass} rounded-sm transition-colors duration-75 ${getSegmentColor(i, status.type)}`}
className={`${segmentClass} rounded-sm transition-colors duration-75 ${getSegmentColor(i, status.type, backgroundOnly)}`}
/>
))}
</div>
Expand Down
Loading