Add background process manager and UI panel
This commit is contained in:
101
packages/ui/src/components/background-process-output-dialog.tsx
Normal file
101
packages/ui/src/components/background-process-output-dialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||
|
||||
interface BackgroundProcessOutputDialogProps {
|
||||
open: boolean
|
||||
instanceId: string
|
||||
process: BackgroundProcess | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||
const [output, setOutput] = createSignal("")
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const process = props.process
|
||||
if (!props.open || !process) {
|
||||
return
|
||||
}
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let active = true
|
||||
|
||||
setLoading(true)
|
||||
serverApi
|
||||
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full" })
|
||||
.then((response) => {
|
||||
if (!active) return
|
||||
setOutput(response.content)
|
||||
setTruncated(response.truncated)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setOutput("Failed to load output.")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||
if (payload?.type === "chunk" && typeof payload.content === "string") {
|
||||
setOutput((prev) => `${prev}${payload.content}`)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
eventSource?.close()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base">
|
||||
<div class="flex flex-col">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
|
||||
<Show when={props.process}>
|
||||
<span class="text-xs text-secondary">
|
||||
{props.process?.title} · {props.process?.id}
|
||||
</span>
|
||||
<span class="text-xs text-secondary mt-1 break-words">{props.process?.command}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button-tertiary" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<Show when={loading()}>
|
||||
<p class="text-xs text-secondary">Loading output...</p>
|
||||
</Show>
|
||||
<Show when={!loading()}>
|
||||
<Show when={truncated()}>
|
||||
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
||||
</Show>
|
||||
<pre class="text-xs whitespace-pre-wrap break-words text-primary bg-surface-secondary border border-base rounded-md p-4">
|
||||
{output()}
|
||||
</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
import AppBar from "@suid/material/AppBar"
|
||||
import Box from "@suid/material/Box"
|
||||
import Divider from "@suid/material/Divider"
|
||||
@@ -28,6 +28,7 @@ import PushPinIcon from "@suid/icons-material/PushPin"
|
||||
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
@@ -56,6 +57,9 @@ import SessionView from "../session/session-view"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import {
|
||||
SESSION_SIDEBAR_EVENT,
|
||||
type SessionSidebarRequestAction,
|
||||
@@ -128,7 +132,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
|
||||
const [resizeStartX, setResizeStartX] = createSignal(0)
|
||||
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["plan", "mcp", "lsp", "plugins"])
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||
"plan",
|
||||
"background-processes",
|
||||
"mcp",
|
||||
"lsp",
|
||||
"plugins",
|
||||
])
|
||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||
|
||||
@@ -152,6 +164,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
persistPinState(side, value)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const instanceId = props.instance.id
|
||||
loadBackgroundProcesses(instanceId).catch((error) => {
|
||||
log.warn("Failed to load background processes", error)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
switch (layoutMode()) {
|
||||
case "desktop": {
|
||||
@@ -314,6 +333,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return state
|
||||
})
|
||||
|
||||
const backgroundProcessList = createMemo(() => getBackgroundProcesses(props.instance.id))
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
||||
const connectionStatusClass = () => {
|
||||
const status = connectionStatus()
|
||||
@@ -326,6 +347,32 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
|
||||
const openBackgroundOutput = (process: BackgroundProcess) => {
|
||||
setSelectedBackgroundProcess(process)
|
||||
setShowBackgroundOutput(true)
|
||||
}
|
||||
|
||||
const closeBackgroundOutput = () => {
|
||||
setShowBackgroundOutput(false)
|
||||
setSelectedBackgroundProcess(null)
|
||||
}
|
||||
|
||||
const stopBackgroundProcess = async (processId: string) => {
|
||||
try {
|
||||
await serverApi.stopBackgroundProcess(props.instance.id, processId)
|
||||
} catch (error) {
|
||||
log.warn("Failed to stop background process", error)
|
||||
}
|
||||
}
|
||||
|
||||
const terminateBackgroundProcess = async (processId: string) => {
|
||||
try {
|
||||
await serverApi.terminateBackgroundProcess(props.instance.id, processId)
|
||||
} catch (error) {
|
||||
log.warn("Failed to terminate background process", error)
|
||||
}
|
||||
}
|
||||
|
||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||
|
||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||
@@ -853,12 +900,74 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} />
|
||||
}
|
||||
|
||||
const renderBackgroundProcesses = () => {
|
||||
const processes = backgroundProcessList()
|
||||
if (processes.length === 0) {
|
||||
return <p class="text-xs text-secondary">No background processes.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={processes}>
|
||||
{(process) => (
|
||||
<div class="rounded-md border border-base bg-surface-secondary p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-primary">{process.title}</span>
|
||||
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
|
||||
<span>Status: {process.status}</span>
|
||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||
<span>Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => openBackgroundOutput(process)}
|
||||
aria-label="Output"
|
||||
title="Output"
|
||||
>
|
||||
<TerminalSquare class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
disabled={process.status !== "running"}
|
||||
onClick={() => stopBackgroundProcess(process.id)}
|
||||
aria-label="Stop"
|
||||
title="Stop"
|
||||
>
|
||||
<XOctagon class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||
onClick={() => terminateBackgroundProcess(process.id)}
|
||||
aria-label="Terminate"
|
||||
title="Terminate"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan",
|
||||
render: renderPlanSectionContent,
|
||||
},
|
||||
{
|
||||
id: "background-processes",
|
||||
label: "Background Shells",
|
||||
render: renderBackgroundProcesses,
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP Servers",
|
||||
@@ -1313,6 +1422,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
commands={instancePaletteCommands()}
|
||||
onExecute={props.onExecuteCommand}
|
||||
/>
|
||||
|
||||
<BackgroundProcessOutputDialog
|
||||
open={showBackgroundOutput()}
|
||||
instanceId={props.instance.id}
|
||||
process={selectedBackgroundProcess()}
|
||||
onClose={closeBackgroundOutput}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
BackgroundProcess,
|
||||
BackgroundProcessListResponse,
|
||||
BackgroundProcessOutputResponse,
|
||||
BinaryCreateRequest,
|
||||
BinaryListResponse,
|
||||
BinaryUpdateRequest,
|
||||
@@ -28,6 +31,12 @@ const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||
|
||||
export const CODENOMAD_API_BASE = API_BASE
|
||||
|
||||
export function buildBackgroundProcessStreamUrl(instanceId: string, processId: string): string {
|
||||
const encodedInstanceId = encodeURIComponent(instanceId)
|
||||
const encodedProcessId = encodeURIComponent(processId)
|
||||
return buildAbsoluteUrl(`/workspaces/${encodedInstanceId}/plugin/background-processes/${encodedProcessId}/stream`)
|
||||
}
|
||||
|
||||
function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
@@ -39,9 +48,41 @@ function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
return path
|
||||
}
|
||||
|
||||
function buildAbsoluteUrl(path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
}
|
||||
if (!API_BASE) {
|
||||
return path
|
||||
}
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `${API_BASE}${normalized}`
|
||||
}
|
||||
|
||||
const httpLogger = getLogger("api")
|
||||
const sseLogger = getLogger("sse")
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
|
||||
function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
if (context) {
|
||||
httpLogger.info(message, context)
|
||||
@@ -52,9 +93,9 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
if (init?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const method = (init?.method ?? "GET").toUpperCase()
|
||||
@@ -186,6 +227,44 @@ export const serverApi = {
|
||||
deleteInstanceData(id: string): Promise<void> {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
listBackgroundProcesses(instanceId: string): Promise<BackgroundProcessListResponse> {
|
||||
return request<BackgroundProcessListResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes`,
|
||||
)
|
||||
},
|
||||
stopBackgroundProcess(instanceId: string, processId: string): Promise<BackgroundProcess> {
|
||||
return request<BackgroundProcess>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/stop`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
terminateBackgroundProcess(instanceId: string, processId: string): Promise<void> {
|
||||
return request(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/terminate`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
fetchBackgroundProcessOutput(
|
||||
instanceId: string,
|
||||
processId: string,
|
||||
options?: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number },
|
||||
): Promise<BackgroundProcessOutputResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.method) {
|
||||
params.set("method", options.method)
|
||||
}
|
||||
if (options?.pattern) {
|
||||
params.set("pattern", options.pattern)
|
||||
}
|
||||
if (options?.lines) {
|
||||
params.set("lines", String(options.lines))
|
||||
}
|
||||
const query = params.toString()
|
||||
const suffix = query ? `?${query}` : ""
|
||||
return request<BackgroundProcessOutputResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
|
||||
)
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from "@opencode-ai/sdk"
|
||||
import { serverEvents } from "./server-events"
|
||||
import type {
|
||||
BackgroundProcess,
|
||||
InstanceStreamEvent,
|
||||
InstanceStreamStatus,
|
||||
WorkspaceEventPayload,
|
||||
@@ -37,6 +38,20 @@ interface TuiToastEvent {
|
||||
}
|
||||
}
|
||||
|
||||
interface BackgroundProcessUpdatedEvent {
|
||||
type: "background.process.updated"
|
||||
properties: {
|
||||
process: BackgroundProcess
|
||||
}
|
||||
}
|
||||
|
||||
interface BackgroundProcessRemovedEvent {
|
||||
type: "background.process.removed"
|
||||
properties: {
|
||||
processId: string
|
||||
}
|
||||
}
|
||||
|
||||
type SSEEvent =
|
||||
| MessageUpdateEvent
|
||||
| MessageRemovedEvent
|
||||
@@ -50,6 +65,8 @@ type SSEEvent =
|
||||
| EventPermissionReplied
|
||||
| EventLspUpdated
|
||||
| TuiToastEvent
|
||||
| BackgroundProcessUpdatedEvent
|
||||
| BackgroundProcessRemovedEvent
|
||||
| { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
type ConnectionStatus = InstanceStreamStatus
|
||||
@@ -126,6 +143,12 @@ class SSEManager {
|
||||
case "lsp.updated":
|
||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||
break
|
||||
case "background.process.updated":
|
||||
this.onBackgroundProcessUpdated?.(instanceId, event as BackgroundProcessUpdatedEvent)
|
||||
break
|
||||
case "background.process.removed":
|
||||
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
|
||||
break
|
||||
default:
|
||||
log.warn("Unknown SSE event type", { type: event.type })
|
||||
}
|
||||
@@ -151,6 +174,8 @@ class SSEManager {
|
||||
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
|
||||
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||
|
||||
getStatus(instanceId: string): ConnectionStatus | null {
|
||||
|
||||
66
packages/ui/src/stores/background-processes.ts
Normal file
66
packages/ui/src/stores/background-processes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
const [backgroundProcesses, setBackgroundProcesses] = createSignal<Map<string, BackgroundProcess[]>>(new Map())
|
||||
|
||||
function setProcesses(instanceId: string, processes: BackgroundProcess[]) {
|
||||
setBackgroundProcesses((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, processes)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function updateProcess(instanceId: string, process: BackgroundProcess) {
|
||||
setBackgroundProcesses((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = next.get(instanceId) ?? []
|
||||
const index = current.findIndex((entry) => entry.id === process.id)
|
||||
const updated = index >= 0 ? [...current.slice(0, index), process, ...current.slice(index + 1)] : [...current, process]
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeProcess(instanceId: string, processId: string) {
|
||||
setBackgroundProcesses((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = next.get(instanceId) ?? []
|
||||
next.set(
|
||||
instanceId,
|
||||
current.filter((entry) => entry.id !== processId),
|
||||
)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function loadBackgroundProcesses(instanceId: string) {
|
||||
const response = await serverApi.listBackgroundProcesses(instanceId)
|
||||
setProcesses(instanceId, response.processes)
|
||||
}
|
||||
|
||||
function getBackgroundProcesses(instanceId: string): BackgroundProcess[] {
|
||||
return backgroundProcesses().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
sseManager.onBackgroundProcessUpdated = (instanceId, event) => {
|
||||
const process = event.properties?.process
|
||||
if (!process) return
|
||||
updateProcess(instanceId, process)
|
||||
}
|
||||
|
||||
sseManager.onBackgroundProcessRemoved = (instanceId, event) => {
|
||||
const processId = event.properties?.processId
|
||||
if (!processId) return
|
||||
removeProcess(instanceId, processId)
|
||||
}
|
||||
|
||||
export {
|
||||
backgroundProcesses,
|
||||
getBackgroundProcesses,
|
||||
loadBackgroundProcesses,
|
||||
removeProcess as removeBackgroundProcess,
|
||||
updateProcess as updateBackgroundProcess,
|
||||
}
|
||||
Reference in New Issue
Block a user