Add background process manager and UI panel

This commit is contained in:
Shantur Rathore
2025-12-24 00:59:41 +00:00
parent 28b66ed0af
commit 575f987b8f
11 changed files with 1446 additions and 5 deletions

View 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>
)
}

View File

@@ -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}
/>
</>
)
}