Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/components/activity/ActivityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function ActivityStateIcon({ item, iconClassName }: { item: ActiveWorkItem; icon
return <CircleAlert className={cn(iconClassName, 'text-warning')} aria-hidden="true" />
}
if (item.state === 'running') {
return <SessionStatusIndicator sessionID={item.session.id} size="sm" />
return <SessionStatusIndicator sessionID={item.session.id} />
}
if (item.outcome === 'failed') {
return <CircleX className={cn(iconClassName, 'text-destructive')} aria-hidden="true" />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/agent/AgentQuickSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function AgentQuickSelect({
const buttonContent = (
<button
style={styleVars as React.CSSProperties}
className="px-2 md:px-3.5 py-1 h-[44px] md:h-[36px] rounded-lg text-sm font-medium border min-w-[56px] max-w-[80px] md:max-w-[100px] flex-shrink-0 flex items-center justify-center transition-all duration-200 active:scale-95 hover:scale-105 shadow-md text-[var(--agent-color)] bg-[var(--agent-bg)] border-[var(--agent-border)] hover:bg-[var(--agent-bg-hover)] hover:border-[var(--agent-border-hover)] shadow-[var(--agent-shadow)] hover:shadow-[var(--agent-shadow-hover)]"
className="px-2 md:px-3.5 py-1 h-10 md:h-9 rounded-lg text-sm font-medium border min-w-[56px] max-w-[80px] md:max-w-[100px] flex-shrink-0 flex items-center justify-center transition-all duration-200 active:scale-95 hover:scale-105 shadow-md text-[var(--agent-color)] bg-[var(--agent-bg)] border-[var(--agent-border)] hover:bg-[var(--agent-bg-hover)] hover:border-[var(--agent-border-hover)] shadow-[var(--agent-shadow)] hover:shadow-[var(--agent-shadow-hover)]"
>
<span className="truncate">{displayName}</span>
</button>
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/components/message/ComposerActivityBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ComposerActivityBar } from './ComposerActivityBar'
import { useSessionStatus } from '@/stores/sessionStatusStore'
import { stubMatchMedia } from '@/test/test-utils'

const originalMatchMedia = window.matchMedia

const resetStore = () => {
useSessionStatus.setState({
statuses: new Map(),
statusCache: new Map(),
statusRevisions: new Map(),
knownSessions: new Set(),
outcomes: new Map(),
parents: new Map(),
directories: new Map(),
runningShells: new Map(),
revision: 0,
})
}

const trackOf = (bar: HTMLElement) => bar.firstElementChild as HTMLElement

describe('ComposerActivityBar', () => {
beforeEach(() => {
resetStore()
})

afterEach(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
})
})

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

expect(container).toBeEmptyDOMElement()
})

it('sits inside the docked composer on mobile and below the composer on desktop', () => {
useSessionStatus.getState().setStatus('session-a', { type: 'busy' })

render(<ComposerActivityBar sessionID="session-a" />)

const bar = screen.getByRole('progressbar', { name: 'Session working' })
expect(bar).toHaveClass('pointer-events-none', 'flex')
expect(bar).toHaveClass('absolute', 'inset-x-3', 'bottom-4', 'md:-bottom-[11px]')
expect(bar).not.toHaveClass('fixed')
})

it('renders a masked segment track with only the sweeping window', () => {
useSessionStatus.getState().setStatus('session-a', { type: 'busy' })

render(<ComposerActivityBar sessionID="session-a" />)

const track = trackOf(screen.getByRole('progressbar', { name: 'Session working' }))
expect(track).toHaveClass('scanner-track', 'relative', 'h-2', 'w-full')

const children = Array.from(track.children) as HTMLElement[]
expect(children).toHaveLength(1)
expect(children[0]).toHaveClass('scanner-sweep')
})

it('tints the track with the info tone while the session is busy', () => {
useSessionStatus.getState().setStatus('session-a', { type: 'busy' })

render(<ComposerActivityBar sessionID="session-a" />)

const track = trackOf(screen.getByRole('progressbar', { name: 'Session working' }))
expect(track).toHaveClass('text-info')
})

it('tints the track with the primary tone while compacting', () => {
useSessionStatus.getState().setStatus('session-a', { type: 'compact' })

render(<ComposerActivityBar sessionID="session-a" />)

const track = trackOf(screen.getByRole('progressbar', { name: 'Compacting' }))
expect(track).toHaveClass('text-primary')
})

it('reports the retry attempt in the label with the warning tone', () => {
useSessionStatus.getState().setStatus('session-a', {
type: 'retry',
attempt: 2,
message: 'rate limited',
next: Date.now() + 5_000,
})

render(<ComposerActivityBar sessionID="session-a" />)

const track = trackOf(screen.getByRole('progressbar', { name: 'Retrying (Retry #2)' }))
expect(track).toHaveClass('text-warning')
})

it('tints the track with the muted foreground tone for background activity', () => {
useSessionStatus.getState().setShellRunning('shell-1', 'session-a', '/repo')

render(<ComposerActivityBar sessionID="session-a" />)

const track = trackOf(screen.getByRole('progressbar', { name: 'Background tasks running' }))
expect(track).toHaveClass('text-muted-foreground')
})

it('keeps sweeping when reduced motion is requested', () => {
stubMatchMedia(true)
useSessionStatus.getState().setStatus('session-a', { type: 'busy' })

render(<ComposerActivityBar sessionID="session-a" />)

const track = trackOf(screen.getByRole('progressbar', { name: 'Session working' }))
expect(track.querySelector('.scanner-sweep')).not.toBeNull()
})
})
60 changes: 60 additions & 0 deletions frontend/src/components/message/ComposerActivityBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ScannerBars, type ScannerTone } from '@/components/ui/scanner-bars'
import { useHasBackgroundActivity, useSessionStatusForSession } from '@/stores/sessionStatusStore'

interface ComposerActivityBarProps {
sessionID: string
}

const ACTIVITY_PRESENTATION = {
background: {
label: 'Background tasks running',
tone: 'muted-foreground',
},
busy: {
label: 'Session working',
tone: 'info',
},
compact: {
label: 'Compacting',
tone: 'primary',
},
retry: {
label: 'Retrying',
tone: 'warning',
},
} as const satisfies Record<string, { label: string; tone: ScannerTone }>

/**
* Session activity scanner shown by the composer. On mobile it sits inside the bottom edge of the
* docked composer; from `md` it is anchored in the gap below the composer frame. A fading lit window
* of 4x8px segments sweeps across the strip. Renders nothing while the session is idle and has no
* background activity; otherwise announces the current activity through an indeterminate
* progressbar.
*/
export function ComposerActivityBar({ sessionID }: ComposerActivityBarProps) {
const status = useSessionStatusForSession(sessionID)
const hasBackgroundActivity = useHasBackgroundActivity(sessionID)

if (status.type === 'idle' && !hasBackgroundActivity) {
return null
}

const activity = status.type === 'idle'
? ACTIVITY_PRESENTATION.background
: ACTIVITY_PRESENTATION[status.type]
const label = status.type === 'retry'
? `${ACTIVITY_PRESENTATION.retry.label} (Retry #${status.attempt})`
: activity.label

return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
className="pointer-events-none absolute inset-x-3 bottom-4 flex md:-bottom-[11px]"
>
<ScannerBars tone={activity.tone} />
</div>
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
22 changes: 4 additions & 18 deletions frontend/src/components/message/ComposerToolsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,27 +249,13 @@ describe('ComposerToolsMenu', () => {
expect(props.onAttachFile).toHaveBeenCalledTimes(1)
})

it('omits the dictate item when onDictate is not provided', async () => {
const user = userEvent.setup()
it('uses the 40px control size for the trigger', () => {
mockPermissionMode()
renderMenu()

await user.click(screen.getByRole('button', { name: 'Composer options' }))
await screen.findByRole('menuitem', { name: 'Attach image or PDF' })

expect(screen.queryByRole('menuitem', { name: 'Dictate' })).not.toBeInTheDocument()
})

it('starts dictation when onDictate is provided', async () => {
const user = userEvent.setup()
mockPermissionMode()
const onDictate = vi.fn()
renderMenu({ onDictate })

await user.click(screen.getByRole('button', { name: 'Composer options' }))
await user.click(await screen.findByRole('menuitem', { name: 'Dictate' }))

expect(onDictate).toHaveBeenCalledTimes(1)
const trigger = screen.getByRole('button', { name: 'Composer options' })
expect(trigger).toHaveClass('size-10', 'p-0')
expect(trigger).not.toHaveClass('p-2')
})
})

Expand Down
18 changes: 9 additions & 9 deletions frontend/src/components/message/ComposerToolsMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Mic, Paperclip, Plus, Shield, ShieldCheck, Target } from 'lucide-react'
import { Paperclip, Plus, Shield, ShieldCheck, Target } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
Expand All @@ -13,6 +13,7 @@ import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeS
import { usePermissionModeToggle } from '@/hooks/usePermissionModeToggle'
import { useSandboxToggle } from '@/hooks/useSandboxToggle'
import { shouldShowSandboxResources } from '@/lib/sandboxResources'
import { cn } from '@/lib/utils'
import { SandboxMenuItems } from './SandboxMenuItems'
import { ToolMenuItemContent } from './ToolMenuItemContent'
import { SessionSandboxResourcesDialog } from './SessionSandboxResourcesDialog'
Expand All @@ -25,9 +26,14 @@ interface ComposerToolsMenuProps {
goalLabel: string
onToggleGoal: () => void
onAttachFile: () => void
onDictate?: () => void
}

/**
* Renders the mobile composer tools trigger: a dropdown for permission mode,
* sandboxing, goal mode, and file attachment.
*
* @param props Session context, goal state, and the attach handler.
*/
export function ComposerToolsMenu({
sessionID,
directory,
Expand All @@ -36,7 +42,6 @@ export function ComposerToolsMenu({
goalLabel,
onToggleGoal,
onAttachFile,
onDictate,
}: ComposerToolsMenuProps) {
const isLocalServer = isLocalOpenCodeServer(useOpenCodeServerId())
const permission = usePermissionModeToggle(sessionID, directory)
Expand All @@ -51,7 +56,7 @@ export function ComposerToolsMenu({
type="button"
aria-label="Composer options"
title="Composer options"
className={`${getIconToggleButtonClassName(permission.isAuto || sandbox.isSandboxed || goalArmed)} flex items-center justify-center min-h-[44px] min-w-[44px]`}
className={cn(getIconToggleButtonClassName(permission.isAuto || sandbox.isSandboxed || goalArmed), 'flex items-center justify-center size-10 p-0')}
>
<Plus className="w-5 h-5" />
</button>
Expand Down Expand Up @@ -82,11 +87,6 @@ export function ComposerToolsMenu({
<DropdownMenuItem onSelect={onAttachFile} className="pl-8">
<ToolMenuItemContent icon={<Paperclip className="h-4 w-4" />} title="Attach image or PDF" />
</DropdownMenuItem>
{onDictate ? (
<DropdownMenuItem onSelect={onDictate} className="pl-8">
<ToolMenuItemContent icon={<Mic className="h-4 w-4" />} title="Dictate" />
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{shouldShowSandboxResources(sandbox) ? (
Expand Down
23 changes: 18 additions & 5 deletions frontend/src/components/message/PromptInput.command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,6 @@ vi.mock('@/hooks/useSessionPermissionMode', () => ({
useSessionPermissionMode: () => ({ data: undefined }),
}))

vi.mock('@/components/ui/session-status-indicator', () => ({
SessionStatusIndicator: () => <div>SessionStatus</div>,
}))

vi.mock('./ComposerToolsMenu', () => ({
ComposerToolsMenu: () => null,
}))
Expand Down Expand Up @@ -274,6 +270,23 @@ describe('PromptInput command submission', () => {
expect(screen.getByRole('status')).toHaveTextContent('Background tasks running')
})

it('keeps the model picker visible while the session is active', 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.getByTestId('model-quick-select').querySelector('button')).not.toBeNull()

rerender(buildPrompt({ isSessionActive: true }))
expect(screen.getByTestId('model-quick-select').querySelector('button')).not.toBeNull()
})

it('disables submit and does not run a command while the model is not ready', async () => {
mocks.useModelSelection.mockReturnValue({
model: null,
Expand Down Expand Up @@ -568,7 +581,7 @@ describe('PromptInput command submission', () => {
renderComponent()

const input = await screen.findByPlaceholderText('Send a message...')
const frame = input.parentElement as HTMLElement
const frame = input.closest('.isolate') as HTMLElement
const tokens = frame.className.split(/\s+/)

expect(tokens).not.toContain('transition-all')
Expand Down
Loading