diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 9e8ef5925..780737b70 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -26,6 +26,10 @@ export type PromptSkillInput = NonNullable[number] export type ActiveSessions = Awaited> +export type ShellInfo = Awaited>['data'][number] + +export type ShellOutputChunk = Awaited>['data'] + export interface SessionPage { items: SessionInfo[] nextCursor?: string @@ -167,6 +171,31 @@ export async function interruptSession(sessionID: string): Promise { await callOpenCode((api) => api.session.interrupt({ sessionID })) } +export async function backgroundSession(sessionID: string): Promise { + await callOpenCode((api) => api.session.background({ sessionID })) +} + +export async function listShells(directory: string): Promise { + 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 { + const { data } = await callOpenCode((api) => + api.shell.output({ id, cursor, limit, ...openCodeLocation(directory) }), + ) + return data +} + +export async function removeShell(id: string, directory: string): Promise { + await callOpenCode((api) => api.shell.remove({ id, ...openCodeLocation(directory) })) +} + export async function stageRevert(sessionID: string, messageID: string): Promise { return callOpenCode((api) => api.session.revert.stage({ sessionID, messageID })) } diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 7b1c26345..6b8ceb2a6 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -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() + + 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: '\nboom\n', + description: 'pnpm test', + metadata: { source: 'shell', state: 'error' }, + time: { created: Date.now() }, + }, + { + id: 'syn-subagent', + type: 'synthetic', + text: '\ndone\n', + description: 'Explore', + metadata: { source: 'subagent', state: 'completed', childID: 'child-1', agent: 'explore' }, + time: { created: Date.now() }, + }, + ] + + render( + , + ) + + expect(screen.getByText('! Shell failed')).toBeInTheDocument() + expect(screen.getByText('· pnpm test')).toBeInTheDocument() + expect(screen.queryByText(/ { + setupSettings({ simpleChatMode: false, showReasoning: false }) + + const messages: SessionMessageInfo[] = [ + { id: 'syn-text', type: 'synthetic', text: 'Background task finished', time: { created: Date.now() } }, + ] + + render() + + 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 }) diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index 21f77265b..03bbe2e15 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -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': @@ -194,6 +196,85 @@ function MessageDivider({ label }: { label: string }) { ) } +type SessionNoticeMessage = Extract + +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
◈ {text}
+ } + + 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 = ( + <> + {completion.heading} + {text && · {text}} + + ) + const childID = completion.childID + + if (childID && onChildSessionClick) { + return ( + + ) + } + + return
{content}
+} + function ShellMessage({ message }: { message: SessionMessageShell }) { const output = message.output?.output ?? '' const statusLabel = message.status === 'running' @@ -490,11 +571,7 @@ const MessageRow = memo(function MessageRow({ } if (message.type === 'synthetic' || message.type === 'system') { - return ( -
- {message.text} -
- ) + return } return null diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index 4e9fc2ba9..fc5fadc97 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -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(), @@ -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): 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() + + expect(screen.getByText('background')).toBeInTheDocument() + }) + + it('does not mark a shell call that finished normally', () => { + renderWithProviders() + + 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() + + expect(screen.getByText('background')).toBeInTheDocument() + expect(screen.queryByText('✓')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 22576fe90..c3596bd14 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -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' + const backgroundIndicator = isBackgrounded ? ( + + background + + ) : null const sandboxIndicator = isSandboxedCommand ? ( ✗} {description} sub-agent + {backgroundIndicator} {subagentSessionId && } ) @@ -267,10 +278,15 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile return (
- ✓ + {isBackgrounded ? ( + + ) : ( + ✓ + )} $ {command} {sandboxIndicator} + {backgroundIndicator} {ran !== undefined && completed !== undefined && ( {((completed - ran) / 1000).toFixed(2)}s @@ -312,6 +328,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile {getStatusIcon()} {part.name} {sandboxIndicator} + {backgroundIndicator} {previewText && isFileTool ? ( ({ + backgroundSession: vi.fn(), + listShells: vi.fn(), + readShellOutput: vi.fn(), + removeShell: vi.fn(), +})) + +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), loading: vi.fn() }, +})) + +const shell = (id: string, sessionID = 'session-1', status: ShellInfo['status'] = 'running'): ShellInfo => ({ + id, + status, + command: `npm run ${id}`, + cwd: '/repo', + shell: 'zsh', + file: `/tmp/${id}.log`, + metadata: { sessionID }, + time: { started: 1 }, +}) + +const runningShellTool = (): SessionMessageAssistant => ({ + id: 'msg-1', + type: 'assistant', + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [ + { + type: 'tool', + id: 'tool-1', + name: 'shell', + state: { status: 'running', input: { command: 'npm test' }, metadata: {} }, + time: { created: 1 }, + }, + ], + time: { created: 1 }, +}) + +function renderBar(messages: SessionMessageInfo[], isSessionActive: boolean) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return { + ...render( + , + { wrapper }, + ), + queryClient, + } +} + +describe('BackgroundWorkBar', () => { + beforeEach(() => { + vi.clearAllMocks() + api.listShells.mockResolvedValue([]) + api.backgroundSession.mockResolvedValue(undefined) + api.removeShell.mockResolvedValue(undefined) + api.readShellOutput.mockResolvedValue({ output: '', cursor: 0, size: 0, truncated: false }) + }) + + it('renders nothing when there is no running or background work', async () => { + const { container } = renderBar([], false) + await waitFor(() => expect(api.listShells).toHaveBeenCalledWith('/repo')) + expect(container).toBeEmptyDOMElement() + }) + + it('moves running shell work to the background', async () => { + renderBar([runningShellTool()], true) + + fireEvent.click(await screen.findByRole('button', { name: /Move to background/ })) + + await waitFor(() => expect(api.backgroundSession).toHaveBeenCalledWith('session-1')) + }) + + it('hides the background action once the session is idle', async () => { + renderBar([runningShellTool()], false) + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + expect(screen.queryByRole('button', { name: /Move to background/ })).not.toBeInTheDocument() + }) + + it('lists only this session\'s running shells and kills one', async () => { + api.listShells.mockResolvedValue([shell('dev'), shell('other', 'session-2'), shell('done', 'session-1', 'exited')]) + renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + + expect(screen.getByText('npm run dev')).toBeInTheDocument() + expect(screen.queryByText('npm run other')).not.toBeInTheDocument() + expect(screen.queryByText('npm run done')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Kill npm run dev' })) + + await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo')) + await waitFor(() => expect(screen.queryByText(/background shell/)).not.toBeInTheDocument()) + }) + + it('shows the tail of a background shell output', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + api.readShellOutput.mockResolvedValue({ output: 'server listening on 3000', cursor: 24, size: 24, truncated: false }) + renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(screen.getByRole('button', { name: 'Output' })) + + expect(await screen.findByText('server listening on 3000')).toBeInTheDocument() + expect(api.readShellOutput).toHaveBeenCalledWith('dev', '/repo', 0, 50_000) + }) + + it('reads the final output when the shell exits during an in-flight poll', async () => { + let resolveFirst: ((value: { output: string; cursor: number; size: number; truncated: boolean }) => void) | undefined + let calls = 0 + api.listShells.mockResolvedValue([shell('dev')]) + api.readShellOutput.mockImplementation(() => { + calls += 1 + if (calls === 1) { + return new Promise<{ output: string; cursor: number; size: number; truncated: boolean }>((resolve) => { + resolveFirst = resolve + }) + } + return Promise.resolve({ output: 'final tail', cursor: 10, size: 10, truncated: false }) + }) + const { queryClient } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(screen.getByRole('button', { name: 'Output' })) + await waitFor(() => expect(calls).toBe(1)) + + api.listShells.mockResolvedValue([]) + await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) + + expect(await screen.findByText('final tail')).toBeInTheDocument() + + resolveFirst?.({ output: '', cursor: 0, size: 0, truncated: false }) + }) +}) diff --git a/frontend/src/components/session/BackgroundWorkBar.tsx b/frontend/src/components/session/BackgroundWorkBar.tsx new file mode 100644 index 000000000..4dbe52d2f --- /dev/null +++ b/frontend/src/components/session/BackgroundWorkBar.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from 'react' +import { ChevronDown, ChevronUp, Loader2, MoveDownRight, Square, Terminal } from 'lucide-react' +import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' +import { readShellOutput, type ShellInfo } from '@/api/opencode' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useBackgroundSession } from '@/hooks/useOpenCode' +import { useKillShell, useSessionShells } from '@/hooks/useSessionShells' + +const SHELL_OUTPUT_LIMIT = 50_000 +const SHELL_OUTPUT_POLL_INTERVAL_MS = 1000 +const BACKGROUNDABLE_TOOLS = new Set(['shell', 'subagent']) + +function hasBackgroundableWork(messages: SessionMessageInfo[]): boolean { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.type !== 'assistant' || message.time.completed !== undefined) continue + return message.content.some( + (part) => part.type === 'tool' && part.state.status === 'running' && BACKGROUNDABLE_TOOLS.has(part.name), + ) + } + return false +} + +interface ShellOutputState { + output: string + truncated: boolean +} + +function appendShellOutput(previous: ShellOutputState, output: string, truncated: boolean): ShellOutputState { + const next = previous.output + output + if (next.length <= SHELL_OUTPUT_LIMIT) return { output: next, truncated: previous.truncated || truncated } + return { output: next.slice(-SHELL_OUTPUT_LIMIT), truncated: true } +} + +function useShellOutput(shellID: string, directory: string, running: boolean): ShellOutputState { + const [state, setState] = useState({ output: '', truncated: false }) + const cursorRef = useRef(0) + + useEffect(() => { + let cancelled = false + let loading = false + + const load = async () => { + if (loading) return + loading = true + let cursor = cursorRef.current + let output = '' + let truncated = false + try { + for (;;) { + const chunk = await readShellOutput(shellID, directory, cursor, SHELL_OUTPUT_LIMIT) + output += chunk.output + if (chunk.truncated) truncated = true + if (chunk.cursor <= cursor) break + cursor = chunk.cursor + if (cursor >= chunk.size) break + const tail = chunk.size - SHELL_OUTPUT_LIMIT + if (tail > cursor) { + cursor = tail + output = '' + truncated = true + } + } + } catch { + return + } finally { + loading = false + if (!cancelled) { + cursorRef.current = cursor + if (output || truncated) setState((previous) => appendShellOutput(previous, output, truncated)) + } + } + } + + void load() + if (!running) { + return () => { + cancelled = true + } + } + const interval = setInterval(() => void load(), SHELL_OUTPUT_POLL_INTERVAL_MS) + return () => { + cancelled = true + clearInterval(interval) + } + }, [shellID, directory, running]) + + return state +} + +function ShellOutputView({ shell, directory, running }: { shell: ShellInfo; directory: string; running: boolean }) { + const { output, truncated } = useShellOutput(shell.id, directory, running) + const bottomRef = useRef(null) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ block: 'end' }) + }, [output]) + + return ( +
+
+        {truncated && [earlier output omitted]{'\n'}}
+        {output || {running ? 'Waiting for output…' : 'No output'}}
+      
+
+
+ ) +} + +interface BackgroundWorkBarProps { + sessionID: string + directory: string + messages: SessionMessageInfo[] + isSessionActive: boolean +} + +export function BackgroundWorkBar({ sessionID, directory, messages, isSessionActive }: BackgroundWorkBarProps) { + const shells = useSessionShells(sessionID, directory) + const killShell = useKillShell(directory) + const backgroundSession = useBackgroundSession() + const [expanded, setExpanded] = useState(false) + const [viewing, setViewing] = useState(null) + + const canBackground = isSessionActive && hasBackgroundableWork(messages) + const viewingRunning = viewing !== null && shells.some((shell) => shell.id === viewing.id) + + if (!canBackground && shells.length === 0 && viewing === null) return null + + return ( +
+
+ {canBackground && ( + + )} + {shells.length > 0 && ( + + )} +
+ + {expanded && shells.length > 0 && ( +
    + {shells.map((shell) => ( +
  • + + {shell.command} + + +
  • + ))} +
+ )} + + { if (!open) setViewing(null) }}> + + + {viewing?.command} + {viewingRunning ? 'Running in the background' : 'Finished'} + + {viewing && } + + +
+ ) +} diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 9bcf2a021..07a89cd43 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -8,6 +8,7 @@ import { renameSession, activateSkill, interruptSession, + backgroundSession, listAgents, runShell, sendPrompt, @@ -375,6 +376,15 @@ export const useSendPrompt = (directory?: string) => { }); }; +export const useBackgroundSession = () => { + return useMutation({ + mutationFn: (sessionID: string) => backgroundSession(sessionID), + onError: (error) => { + showToast.error(error instanceof Error ? error.message : "Failed to move work to the background"); + }, + }); +}; + const ABORT_RETRY_INTERVAL_MS = 3000; const MAX_ABORT_RETRIES = 10; diff --git a/frontend/src/hooks/useSSE.test.tsx b/frontend/src/hooks/useSSE.test.tsx index 5d30eff87..57490c0e3 100644 --- a/frontend/src/hooks/useSSE.test.tsx +++ b/frontend/src/hooks/useSSE.test.tsx @@ -491,6 +491,63 @@ describe('useSSE', () => { expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) + + unmount() + }) + + it('tracks background shells from shell lifecycle events', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const running = (id: string) => ({ + id, + status: 'running', + command: `sleep ${id}`, + cwd: '/repo', + shell: 'zsh', + file: `/tmp/${id}.log`, + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [running('shell-1')]) + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.created', + directory: '/repo', + data: { info: running('shell-2') }, + }) + MockEventSource.instances[0].emit('message', { + type: 'shell.exited', + directory: '/repo', + data: { id: 'shell-1', exit: 0, status: 'exited' }, + }) + }) + + expect(queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.map((shell) => shell.id)) + .toEqual(['shell-2']) + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.deleted', + directory: '/repo', + data: { id: 'shell-2' }, + }) + }) + + expect(queryClient.getQueryData(['opencode', 'shells', '/repo'])).toEqual([]) unmount() }) diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 26a542683..7f2efc314 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -1,13 +1,13 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { SessionInfo, V2Event } from '@opencode-manager/shared/opencode' -import { invalidateSessionListCaches, invalidateSessionListCachesDebounced } from '@/lib/queryInvalidation' +import { invalidateSessionListCaches, invalidateSessionListCachesDebounced, shellsQueryKey } from '@/lib/queryInvalidation' import { showToast } from '@/lib/toast' import { useSessionStatus } from '@/stores/sessionStatusStore' import { useSendErrorStore } from '@/stores/sendErrorStore' import { openCodeEventStream } from '@/lib/opencode-event-stream' import type { EventStreamSubscription } from '@/lib/opencode-event-stream' -import { listActiveSessions } from '@/api/opencode' +import { listActiveSessions, type ShellInfo } from '@/api/opencode' const STATUS_POLL_INTERVAL_MS = 5000 @@ -141,6 +141,23 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) invalidateSessionListCachesDebounced(queryClient) break + case 'shell.created': { + const info = event.data.info + queryClient.setQueryData(shellsQueryKey(cacheDirectory), (current) => + current ? [...current.filter((shell) => shell.id !== info.id), info] : current, + ) + break + } + + case 'shell.exited': + case 'shell.deleted': { + const id = event.data.id + queryClient.setQueryData(shellsQueryKey(cacheDirectory), (current) => + current?.filter((shell) => shell.id !== id), + ) + break + } + case 'installation.updated': showToast.success(`OpenCode updated to v${event.data.version}`, { description: 'The server has been successfully upgraded.', @@ -235,6 +252,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) const handleResync = () => { if (!mountedRef.current) return invalidateSessionListCaches(queryClient) + queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) refreshCurrentSession() } diff --git a/frontend/src/hooks/useSessionShells.ts b/frontend/src/hooks/useSessionShells.ts new file mode 100644 index 000000000..0be57f441 --- /dev/null +++ b/frontend/src/hooks/useSessionShells.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { listShells, removeShell, type ShellInfo } from '@/api/opencode' +import { shellsQueryKey } from '@/lib/queryInvalidation' +import { showToast } from '@/lib/toast' + +export function useSessionShells(sessionID: string | undefined, directory: string | undefined) { + const query = useQuery({ + queryKey: shellsQueryKey(directory), + queryFn: () => listShells(directory ?? ''), + enabled: Boolean(directory), + staleTime: Infinity, + }) + + const shells = useMemo( + () => + (query.data ?? []).filter( + (shell) => shell.status === 'running' && shell.metadata.sessionID === sessionID, + ), + [query.data, sessionID], + ) + + return shells +} + +export function useKillShell(directory: string | undefined) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (id: string) => removeShell(id, directory ?? ''), + onSuccess: (_result, id) => { + queryClient.setQueryData(shellsQueryKey(directory), (current) => + current?.filter((shell) => shell.id !== id), + ) + }, + onError: (error) => { + showToast.error(error instanceof Error ? error.message : 'Failed to kill shell command') + }, + }) +} diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index 6475d1d41..cade4266b 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -5,6 +5,10 @@ export function sessionTranscriptQueryKey(sessionID: string | null | undefined) return ['opencode', 'transcript', sessionID] } +export function shellsQueryKey(directory: string | null | undefined) { + return ['opencode', 'shells', directory] +} + export function invalidateProviderCaches(queryClient: QueryClient) { queryClient.invalidateQueries({ queryKey: ['provider-credentials'] }) queryClient.invalidateQueries({ queryKey: ['provider-auth-methods'] }) diff --git a/frontend/src/lib/session-projection/applySessionEvent.test.ts b/frontend/src/lib/session-projection/applySessionEvent.test.ts index bb36c1e1f..33c343829 100644 --- a/frontend/src/lib/session-projection/applySessionEvent.test.ts +++ b/frontend/src/lib/session-projection/applySessionEvent.test.ts @@ -745,4 +745,43 @@ describe('hydrateSessionTranscript', () => { }, ]) }) + + it('settles an assistant left open by an execution that ended before its first step', () => { + const openAssistant: SessionMessageAssistant = { + id: 'msg_open', + type: 'assistant', + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [], + time: { created: 1100 }, + } + const transcript = hydrateSessionTranscript({ + messages: [ + { id: USER_INBOX_ID, type: 'user', text: 'hi', time: { created: 1000 } }, + openAssistant, + { id: 'msg_idle', type: 'idle', outcome: 'interrupted', time: { created: 1200 } }, + ], + pending: [], + status: 'idle', + }) + + expect(transcript.messages[1]).toMatchObject({ id: 'msg_open', time: { created: 1100, completed: 1200 } }) + }) + + it('leaves the assistant of a still-running execution open', () => { + const messages = [ + { id: USER_INBOX_ID, type: 'user' as const, text: 'hi', time: { created: 1000 } }, + { + id: 'msg_running', + type: 'assistant' as const, + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [], + time: { created: 1100 }, + }, + ] + const transcript = hydrateSessionTranscript({ messages, pending: [], status: 'busy' }) + + expect(transcript.messages).toBe(messages) + }) }) diff --git a/frontend/src/lib/session-projection/applySessionEvent.ts b/frontend/src/lib/session-projection/applySessionEvent.ts index e444222f9..2686505cf 100644 --- a/frontend/src/lib/session-projection/applySessionEvent.ts +++ b/frontend/src/lib/session-projection/applySessionEvent.ts @@ -416,9 +416,25 @@ export function admitInboxItem(transcript: SessionTranscript, item: SessionInbox return commitDraft(draft) } +function settleEndedAssistants(messages: SessionMessageInfo[]): SessionMessageInfo[] { + let endedAt: number | undefined + let settled: SessionMessageInfo[] | undefined + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.type === 'idle') { + endedAt = message.time.created + continue + } + if (message?.type !== 'assistant' || message.time.completed !== undefined || endedAt === undefined) continue + settled ??= [...messages] + settled[index] = { ...message, time: { ...message.time, completed: endedAt } } + } + return settled ?? messages +} + export function hydrateSessionTranscript(snapshot: SessionSnapshot): SessionTranscript { return snapshot.pending.reduce(admitInboxItem, { - messages: snapshot.messages, + messages: settleEndedAssistants(snapshot.messages), pending: [], status: snapshot.status, }) diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 1aae8b587..6dc4194b8 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -47,6 +47,7 @@ import { MinimizedFormIndicator } from "@/components/session/MinimizedFormIndica import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; import { SourceControlPanel } from "@/components/source-control"; import { SessionSendErrorBanner } from "@/components/session/SessionSendErrorBanner"; +import { BackgroundWorkBar } from "@/components/session/BackgroundWorkBar"; import { useDialogParam } from "@/hooks/useDialogParam"; import { useSidebarAction } from "@/hooks/useSidebarAction"; import { SessionMoreButton } from "@/components/navigation/SessionMoreButton"; @@ -637,6 +638,12 @@ export function SessionDetail() { /> )} + ({ } : null)), })) +vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.export.test.tsx b/frontend/src/pages/__tests__/SessionDetail.export.test.tsx index 3d24f66c9..8c67f955a 100644 --- a/frontend/src/pages/__tests__/SessionDetail.export.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.export.test.tsx @@ -175,6 +175,7 @@ vi.mock('@/api/repos', () => ({ initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), })) +vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx index 5d6a3762a..7874a3d16 100644 --- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx @@ -155,6 +155,7 @@ vi.mock('@/api/repos', () => ({ initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), })) +vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index 37726dcc0..5c7fec070 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -130,6 +130,7 @@ vi.mock('@/api/repos', () => ({ initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), })) +vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null) })) diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index 7f803ec40..8f4dc719f 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -149,6 +149,7 @@ vi.mock('@/api/repos', () => ({ initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), })) +vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null), }))