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
29 changes: 29 additions & 0 deletions frontend/src/api/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export type PromptSkillInput = NonNullable<SessionPromptInput['skills']>[number]

export type ActiveSessions = Awaited<ReturnType<OpenCodeApi['session']['active']>>

export type ShellInfo = Awaited<ReturnType<OpenCodeApi['shell']['list']>>['data'][number]

export type ShellOutputChunk = Awaited<ReturnType<OpenCodeApi['shell']['output']>>['data']

export interface SessionPage {
items: SessionInfo[]
nextCursor?: string
Expand Down Expand Up @@ -167,6 +171,31 @@ export async function interruptSession(sessionID: string): Promise<void> {
await callOpenCode((api) => api.session.interrupt({ sessionID }))
}

export async function backgroundSession(sessionID: string): Promise<void> {
await callOpenCode((api) => api.session.background({ sessionID }))
}

export async function listShells(directory: string): Promise<ShellInfo[]> {
const { data } = await callOpenCode((api) => api.shell.list(openCodeLocation(directory)))
return data
}

export async function readShellOutput(
id: string,
directory: string,
cursor: number,
limit: number,
): Promise<ShellOutputChunk> {
const { data } = await callOpenCode((api) =>
api.shell.output({ id, cursor, limit, ...openCodeLocation(directory) }),
)
return data
}

export async function removeShell(id: string, directory: string): Promise<void> {
await callOpenCode((api) => api.shell.remove({ id, ...openCodeLocation(directory) }))
}

export async function stageRevert(sessionID: string, messageID: string): Promise<SessionRevert> {
return callOpenCode((api) => api.session.revert.stage({ sessionID, messageID }))
}
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/components/message/MessageThread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,81 @@ describe('MessageThread', () => {
useUIState.getState().setIsEditingMessage(false)
})

it('renders instruction updates as a single notice line without the instruction body', () => {
setupSettings({ simpleChatMode: false, showReasoning: false })

const messages: SessionMessageInfo[] = [
{ id: 'sys-1', type: 'system', text: '# Code Mode\nfull instruction body', time: { created: Date.now() } },
{
id: 'sys-2',
type: 'system',
text: 'agents body',
description: 'Instructions updated: AGENTS.md',
time: { created: Date.now() },
},
]

render(<MessageThread sessionID="test-session" messages={messages} pending={[]} />)

expect(screen.getByText(/Instructions updated$/)).toBeInTheDocument()
expect(screen.getByText(/Instructions updated: AGENTS\.md/)).toBeInTheDocument()
expect(screen.queryByText(/full instruction body/)).not.toBeInTheDocument()
expect(screen.queryByText(/agents body/)).not.toBeInTheDocument()
})

it('renders background completion notices as status lines instead of raw payloads', () => {
setupSettings({ simpleChatMode: false, showReasoning: false })

const onChildSessionClick = vi.fn()
const messages: SessionMessageInfo[] = [
{
id: 'syn-shell',
type: 'synthetic',
text: '<shell id="job-1" state="error" command="pnpm test">\nboom\n</shell>',
description: 'pnpm test',
metadata: { source: 'shell', state: 'error' },
time: { created: Date.now() },
},
{
id: 'syn-subagent',
type: 'synthetic',
text: '<subagent sessionID="child-1" state="completed" description="Explore">\ndone\n</subagent>',
description: 'Explore',
metadata: { source: 'subagent', state: 'completed', childID: 'child-1', agent: 'explore' },
time: { created: Date.now() },
},
]

render(
<MessageThread
sessionID="test-session"
messages={messages}
pending={[]}
onChildSessionClick={onChildSessionClick}
/>,
)

expect(screen.getByText('! Shell failed')).toBeInTheDocument()
expect(screen.getByText('· pnpm test')).toBeInTheDocument()
expect(screen.queryByText(/<shell/)).not.toBeInTheDocument()
expect(screen.queryByText(/<subagent/)).not.toBeInTheDocument()

fireEvent.click(screen.getByText('↳ Explore finished'))
expect(onChildSessionClick).toHaveBeenCalledWith('child-1')
})

it('renders a synthetic message that only carries text', () => {
setupSettings({ simpleChatMode: false, showReasoning: false })

const messages: SessionMessageInfo[] = [
{ id: 'syn-text', type: 'synthetic', text: 'Background task finished', time: { created: Date.now() } },
]

render(<MessageThread sessionID="test-session" messages={messages} pending={[]} />)

expect(screen.getByText(/Background task finished/)).toBeInTheDocument()
})

it('renders assistant message with only a subagent part as standalone row without header', () => {
setupSettings({ simpleChatMode: false, showReasoning: false })

Expand Down
91 changes: 84 additions & 7 deletions frontend/src/components/message/MessageThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ function hasRenderableContent(
return false
})
case 'synthetic':
case 'system':
return message.text.trim().length > 0
case 'system': {
const notice = sessionNoticeContent(message)
return notice.completion !== undefined || notice.text.trim().length > 0
}
case 'shell':
case 'skill':
case 'compaction':
Expand Down Expand Up @@ -194,6 +196,85 @@ function MessageDivider({ label }: { label: string }) {
)
}

type SessionNoticeMessage = Extract<SessionMessageInfo, { type: 'synthetic' | 'system' }>

interface SessionNoticeContent {
text: string
completion?: {
heading: string
state?: string
childID?: string
}
}

function noticeMetadataString(message: SessionNoticeMessage, key: string): string | undefined {
const value = message.metadata?.[key]
return typeof value === 'string' ? value : undefined
}

function sessionNoticeContent(message: SessionNoticeMessage): SessionNoticeContent {
const source = noticeMetadataString(message, 'source')
const text = message.type === 'system'
? message.description ?? 'Instructions updated'
: message.description ?? (source === 'shell' || source === 'subagent' ? '' : message.text)
if (source !== 'shell' && source !== 'subagent') return { text }

const state = noticeMetadataString(message, 'state')
const agent = noticeMetadataString(message, 'agent')
const actor = source === 'shell' ? 'Shell' : agent ? agent.charAt(0).toUpperCase() + agent.slice(1) : 'Subagent'
const status = state === 'error' ? 'failed' : state === 'completed' || state === undefined ? 'finished' : state
return {
text: source === 'shell' ? text.replace(/\s+/g, ' ').trim() : text,
completion: {
heading: `${state === 'completed' ? '↳' : '!'} ${actor} ${status}`,
state,
childID: source === 'subagent' ? noticeMetadataString(message, 'childID') : undefined,
},
}
}

function SessionNotice({
message,
onChildSessionClick,
}: {
message: SessionNoticeMessage
onChildSessionClick?: (sessionId: string) => void
}) {
const { text, completion } = sessionNoticeContent(message)

if (!completion) {
return <div className="my-1 px-3 py-1 text-xs text-muted-foreground truncate">◈ {text}</div>
}

const tone = completion.state === 'error'
? 'text-destructive'
: completion.state === 'cancelled'
? 'text-amber-600 dark:text-amber-400'
: 'text-blue-600 dark:text-blue-400'
const content = (
<>
<span className={tone}>{completion.heading}</span>
{text && <span className="text-muted-foreground"> · {text}</span>}
</>
)
const childID = completion.childID

if (childID && onChildSessionClick) {
return (
<button
type="button"
onClick={() => onChildSessionClick(childID)}
aria-live="polite"
className="my-1 block w-full px-3 py-1 text-left text-xs truncate hover:underline"
>
{content}
</button>
)
}

return <div aria-live="polite" className="my-1 px-3 py-1 text-xs truncate">{content}</div>
}

function ShellMessage({ message }: { message: SessionMessageShell }) {
const output = message.output?.output ?? ''
const statusLabel = message.status === 'running'
Expand Down Expand Up @@ -490,11 +571,7 @@ const MessageRow = memo(function MessageRow({
}

if (message.type === 'synthetic' || message.type === 'system') {
return (
<div className="my-1 px-3 py-1.5 text-xs italic text-muted-foreground whitespace-pre-wrap">
{message.text}
</div>
)
return <SessionNotice message={message} onChildSessionClick={onChildSessionClick} />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return null
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/message/ToolCallPart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import type { PermissionRequest, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode'
import { ToolCallPart } from './ToolCallPart'
import { useUserBash } from '@/stores/userBashStore'

const mocks = vi.hoisted(() => ({
useSettings: vi.fn(),
Expand Down Expand Up @@ -99,3 +100,51 @@ describe('ToolCallPart permission indicator', () => {
expect(screen.queryByText('awaiting permission')).not.toBeInTheDocument()
})
})

describe('ToolCallPart background indicator', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.useSettings.mockReturnValue({
preferences: { expandToolCalls: false },
isLoading: false,
updateSettings: vi.fn(),
isUpdating: false,
})
mocks.useToolCallPermission.mockReturnValue(null)
useUserBash.setState({ userBashCommands: new Map() })
})

const completedShell = (metadata: Record<string, unknown>): SessionMessageAssistantTool => ({
type: 'tool',
id: 'call_2',
name: 'shell',
time: { created: 1, ran: 2, completed: 3 },
state: {
status: 'completed',
input: { command: 'npm run dev' },
content: [{ type: 'text', text: 'Command moved to the background (shell ID: sh_1).' }],
metadata,
},
})

it('marks a shell call that returned while its command keeps running', () => {
renderWithProviders(<ToolCallPart part={completedShell({ status: 'running', shellID: 'sh_1' })} messageID="msg_1" />)

expect(screen.getByText('background')).toBeInTheDocument()
})

it('does not mark a shell call that finished normally', () => {
renderWithProviders(<ToolCallPart part={completedShell({ status: 'completed', shellID: 'sh_1' })} messageID="msg_1" />)

expect(screen.queryByText('background')).not.toBeInTheDocument()
})

it('marks a backgrounded user-bash command with a background indicator', () => {
useUserBash.setState({ userBashCommands: new Map([['npm run dev', Date.now()]]) })

renderWithProviders(<ToolCallPart part={completedShell({ status: 'running', shellID: 'sh_1' })} messageID="msg_1" />)

expect(screen.getByText('background')).toBeInTheDocument()
expect(screen.queryByText('✓')).not.toBeInTheDocument()
})
})
23 changes: 20 additions & 3 deletions frontend/src/components/message/ToolCallPart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,16 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile

const previewText = getPreviewText()
const isFileTool = ['read', 'write', 'edit', 'patch'].includes(part.name)
const isBackgrounded = part.state.status === 'completed' && toolMetadata(part).status === 'running'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const backgroundIndicator = isBackgrounded ? (
<Badge
variant="outline"
className="shrink-0 border-yellow-600/40 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"
title="Continues running in the background; the session is notified when it finishes"
>
background
</Badge>
) : null
const sandboxIndicator = isSandboxedCommand ? (
<Badge
variant="outline"
Expand All @@ -206,8 +216,8 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile

if (isSubagent) {
const status = part.state.status
const isRunning = status === 'running' && subagentSessionStatus.type !== 'idle'
const isCompleted = status === 'completed' || (status === 'running' && !!subagentSessionId && subagentSessionStatus.type === 'idle')
const isRunning = (status === 'running' || isBackgrounded) && subagentSessionStatus.type !== 'idle'
const isCompleted = !isRunning && (status === 'completed' || (status === 'running' && !!subagentSessionId && subagentSessionStatus.type === 'idle'))
const isError = status === 'error'
const description = previewText || 'Sub-agent task'

Expand All @@ -231,6 +241,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile
{isError && <span className="text-red-600 text-sm font-medium">✗</span>}
<span className="font-medium text-foreground truncate">{description}</span>
<span className="text-[11px] font-medium text-orange-600 dark:text-orange-400 shrink-0">sub-agent</span>
{backgroundIndicator}
{subagentSessionId && <ExternalLink className="w-3 h-3 shrink-0 text-blue-600 dark:text-blue-400" />}
</div>
)
Expand Down Expand Up @@ -267,10 +278,15 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile
return (
<div className="my-2">
<div className="flex items-center gap-2 text-sm mb-2">
<span className="text-green-600 dark:text-green-400">✓</span>
{isBackgrounded ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-yellow-600 dark:text-yellow-400" />
) : (
<span className="text-green-600 dark:text-green-400">✓</span>
)}
<span className="font-medium">$</span>
<span className="text-foreground">{command}</span>
{sandboxIndicator}
{backgroundIndicator}
{ran !== undefined && completed !== undefined && (
<span className="text-muted-foreground text-xs ml-auto">
{((completed - ran) / 1000).toFixed(2)}s
Expand Down Expand Up @@ -312,6 +328,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile
<span className={getStatusColor()}>{getStatusIcon()}</span>
<span className="font-medium">{part.name}</span>
{sandboxIndicator}
{backgroundIndicator}

{previewText && isFileTool ? (
<span
Expand Down
Loading