diff --git a/package-lock.json b/package-lock.json index 25651b70..1d49000c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.8.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "7zip-bin": "^5.2.0", "google-auth-library": "^10.5.0" @@ -7384,7 +7384,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server" @@ -7418,7 +7418,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", @@ -7455,14 +7455,14 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@tauri-apps/cli": "^2.9.4" } }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", diff --git a/package.json b/package.json index 64f5b1a4..c24a5e70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.8.1", + "version": "0.9.0", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 38848347..03431b22 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.8.1", + "version": "0.9.0", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b1aef738..27f4104b 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.8.1", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 3c11a3e6..1b10afc9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.8.1", + "version": "0.9.0", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 38cc1992..08ed8ff7 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -95,6 +95,26 @@ export interface FileSystemListResponse { metadata: FileSystemListingMetadata } +export interface FileSystemCreateFolderRequest { + /** + * Path identifier for the currently browsed directory. + * Matches the `path` parameter used for `/api/filesystem`. + */ + parentPath?: string + /** Single folder name (no separators). */ + name: string +} + +export interface FileSystemCreateFolderResponse { + /** + * Path identifier that can be passed back to `/api/filesystem` to browse the new folder. + * Relative for restricted listings, absolute for unrestricted. + */ + path: string + /** Absolute folder path on the server host. */ + absolutePath: string +} + export const WINDOWS_DRIVES_ROOT = "__drives__" export interface WorkspaceFileResponse { diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index 29ddb1c1..e5820f3d 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -2,6 +2,7 @@ import fs from "fs" import os from "os" import path from "path" import { + FileSystemCreateFolderResponse, FileSystemEntry, FileSystemListResponse, FileSystemListingMetadata, @@ -56,6 +57,30 @@ export class FileSystemBrowser { return this.listRestrictedWithMetadata(targetPath, includeFiles) } + createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse { + const name = this.normalizeFolderName(folderName) + + if (this.unrestricted) { + const resolvedParent = this.resolveUnrestrictedPath(parentPath) + if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) { + throw new Error("Cannot create folders at drive root") + } + this.assertDirectoryExists(resolvedParent) + const absolutePath = this.resolveAbsoluteChild(resolvedParent, name) + fs.mkdirSync(absolutePath) + return { path: absolutePath, absolutePath } + } + + const normalizedParent = this.normalizeRelativePath(parentPath) + const parentAbsolute = this.toRestrictedAbsolute(normalizedParent) + this.assertDirectoryExists(parentAbsolute) + + const relativePath = this.buildRelativePath(normalizedParent, name) + const absolutePath = this.toRestrictedAbsolute(relativePath) + fs.mkdirSync(absolutePath) + return { path: relativePath, absolutePath } + } + readFile(relativePath: string): string { if (this.unrestricted) { throw new Error("readFile is not available in unrestricted mode") @@ -157,6 +182,41 @@ export class FileSystemBrowser { return { entries, metadata } } + private normalizeFolderName(input: string): string { + const name = input.trim() + if (!name) { + throw new Error("Folder name is required") + } + + if (name === "." || name === "..") { + throw new Error("Invalid folder name") + } + + if (name.startsWith("~")) { + throw new Error("Invalid folder name") + } + + if (name.includes("/") || name.includes("\\")) { + throw new Error("Folder name must not include path separators") + } + + if (name.includes("\u0000")) { + throw new Error("Invalid folder name") + } + + return name + } + + private assertDirectoryExists(directory: string) { + if (!fs.existsSync(directory)) { + throw new Error(`Directory does not exist: ${directory}`) + } + const stats = fs.statSync(directory) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${directory}`) + } + } + private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] { const dirents = fs.readdirSync(directory, { withFileTypes: true }) const results: FileSystemEntry[] = [] diff --git a/packages/server/src/server/routes/filesystem.ts b/packages/server/src/server/routes/filesystem.ts index d919c29e..4f5895f4 100644 --- a/packages/server/src/server/routes/filesystem.ts +++ b/packages/server/src/server/routes/filesystem.ts @@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({ includeFiles: z.coerce.boolean().optional(), }) +const FilesystemCreateFolderSchema = z.object({ + parentPath: z.string().optional(), + name: z.string(), +}) + export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/filesystem", async (request, reply) => { const query = FilesystemQuerySchema.parse(request.query ?? {}) @@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) return { error: (error as Error).message } } }) + + app.post("/api/filesystem/folders", async (request, reply) => { + const body = FilesystemCreateFolderSchema.parse(request.body ?? {}) + + try { + const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name) + reply.code(201) + return created + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "EEXIST") { + reply.code(409).type("text/plain").send("Folder already exists") + return + } + if (err?.code === "EACCES" || err?.code === "EPERM") { + reply.code(403).type("text/plain").send("Permission denied") + return + } + + reply.code(400).type("text/plain").send((error as Error).message) + } + }) } diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 203d984a..06b6813b 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -1,4 +1,4 @@ -import { ChildProcess, spawn } from "child_process" +import { ChildProcess, spawn, spawnSync } from "child_process" import { existsSync, statSync } from "fs" import path from "path" import { EventBus } from "../events/bus" @@ -122,10 +122,12 @@ export class WorkspaceRuntime { }, "Launching OpenCode process", ) + const detached = process.platform !== "win32" const child = spawn(spec.command, spec.args, { cwd: options.folder, env, stdio: ["ignore", "pipe", "pipe"], + detached, ...spec.options, }) @@ -259,10 +261,96 @@ export class WorkspaceRuntime { const child = managed.child this.logger.info({ workspaceId }, "Stopping OpenCode process") + const pid = child.pid + if (!pid) { + this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop") + return + } + + const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null + + const tryKillPosixGroup = (signal: NodeJS.Signals) => { + try { + // Negative PID targets the process group (POSIX). + process.kill(-pid, signal) + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "ESRCH") { + return true + } + this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group") + return false + } + } + + const tryKillSinglePid = (signal: NodeJS.Signals) => { + try { + process.kill(pid, signal) + return true + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err?.code === "ESRCH") { + return true + } + this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID") + return false + } + } + + const tryTaskkill = (force: boolean) => { + const args = ["/PID", String(pid), "/T"] + if (force) { + args.push("/F") + } + + try { + const result = spawnSync("taskkill", args, { encoding: "utf8" }) + const exitCode = result.status + if (exitCode === 0) { + return true + } + // If the PID is already gone, treat it as success. + const stderr = (result.stderr ?? "").toString().toLowerCase() + const stdout = (result.stdout ?? "").toString().toLowerCase() + const combined = `${stdout}\n${stderr}` + if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) { + return true + } + this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed") + return false + } catch (error) { + this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute") + return false + } + } + + const sendStopSignal = (signal: NodeJS.Signals) => { + if (process.platform === "win32") { + // Best-effort: terminate the whole process tree rooted at pid. + // Use /F only for escalation. + tryTaskkill(signal === "SIGKILL") + return + } + + // Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server. + const groupOk = tryKillPosixGroup(signal) + if (!groupOk) { + // Fallback to direct PID kill. + tryKillSinglePid(signal) + } + } + await new Promise((resolve, reject) => { + let escalationTimer: NodeJS.Timeout | null = null + const cleanup = () => { child.removeListener("exit", onExit) child.removeListener("error", onError) + if (escalationTimer) { + clearTimeout(escalationTimer) + escalationTimer = null + } } const onExit = () => { @@ -274,32 +362,30 @@ export class WorkspaceRuntime { reject(error) } - const resolveIfAlreadyExited = () => { - if (child.exitCode !== null || child.signalCode !== null) { - this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited") - cleanup() - resolve() - return true - } - return false + if (isAlreadyExited()) { + this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited") + cleanup() + resolve() + return } child.once("exit", onExit) child.once("error", onError) - if (resolveIfAlreadyExited()) { - return - } + this.logger.debug( + { workspaceId, pid, detached: process.platform !== "win32" }, + "Sending SIGTERM to workspace process (tree/group)", + ) + sendStopSignal("SIGTERM") - this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process") - child.kill("SIGTERM") - setTimeout(() => { - if (!child.killed) { - this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing") - child.kill("SIGKILL") - } else { - this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout") + escalationTimer = setTimeout(() => { + escalationTimer = null + if (isAlreadyExited()) { + this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation") + return } + this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating") + sendStopSignal("SIGKILL") }, 2000) }) } diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index c9d02a3b..4c26c3cf 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.8.1", + "version": "0.9.0", "private": true, "scripts": { "dev": "tauri dev", diff --git a/packages/ui/package.json b/packages/ui/package.json index d301784f..2cae67fa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.8.1", + "version": "0.9.0", "private": true, "type": "module", "scripts": { diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index fce38bad..413e6245 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa const AlertDialog: Component = () => { let primaryButtonRef: HTMLButtonElement | undefined + let promptInputRef: HTMLInputElement | undefined createEffect(() => { - if (alertDialogState()) { - queueMicrotask(() => { - primaryButtonRef?.focus() - }) - } + const state = alertDialogState() + if (!state) return + + queueMicrotask(() => { + if (state.type === "prompt") { + promptInputRef?.focus() + promptInputRef?.select() + return + } + primaryButtonRef?.focus() + }) }) return ( @@ -118,25 +125,29 @@ const AlertDialog: Component = () => { - -
- - setInputValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - dismiss(true, payload, inputValue()) - } - }} - /> -
-
+ +
+ + { + promptInputRef = el + }} + class="form-input mt-2" + value={inputValue()} + placeholder={payload.inputPlaceholder || ""} + autocapitalize="off" + autocorrect="off" + spellcheck={false} + onInput={(e) => setInputValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + dismiss(true, payload, inputValue()) + } + }} + /> +
+
{(isConfirm || isPrompt) && ( diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx index c3622a6e..bab1ab8d 100644 --- a/packages/ui/src/components/directory-browser-dialog.tsx +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -1,8 +1,9 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" -import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid" +import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" +import { showAlertDialog, showPromptDialog } from "../stores/alerts" function normalizePathKey(input?: string | null) { if (!input || input === "." || input === "./") { @@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component = (props) = const [rootPath, setRootPath] = createSignal("") const [loading, setLoading] = createSignal(false) const [error, setError] = createSignal(null) + const [creatingFolder, setCreatingFolder] = createSignal(false) const [directoryChildren, setDirectoryChildren] = createSignal>(new Map()) const [loadingPaths, setLoadingPaths] = createSignal>(new Set()) const [currentPathKey, setCurrentPathKey] = createSignal(null) @@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component = (props) = props.onSelect(absolutePath) } + async function handleCreateFolder() { + if (creatingFolder()) return + const metadata = currentMetadata() + if (!metadata || metadata.pathKind === "drives") { + return + } + + const name = + (await showPromptDialog("Create a new folder in the current directory.", { + title: "New Folder", + inputLabel: "Folder name", + inputPlaceholder: "e.g. my-new-project", + confirmLabel: "Create", + cancelLabel: "Cancel", + }))?.trim() ?? "" + if (!name) return + + if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) { + showAlertDialog("Please enter a single folder name.", { + variant: "warning", + detail: "Folder names cannot include slashes, '..', or '~'.", + }) + return + } + + setCreatingFolder(true) + try { + const parentKey = normalizePathKey(metadata.currentPath) + metadataCache.delete(parentKey) + inFlightRequests.delete(parentKey) + setDirectoryChildren((prev) => { + const next = new Map(prev) + next.delete(parentKey) + return next + }) + + const created = await serverApi.createFileSystemFolder(metadata.currentPath, name) + await navigateTo(created.path) + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to create folder" + showAlertDialog(message, { variant: "error", title: "Unable to create folder" }) + } finally { + setCreatingFolder(false) + } + } + function isPathLoading(path: string) { return loadingPaths().has(normalizePathKey(path)) } @@ -290,19 +338,32 @@ const DirectoryBrowserDialog: Component = (props) = Current folder {currentAbsolutePath()}
- +
+ + +
= (props) => { function handleKeyDown(e: KeyboardEvent) { + let activeElement: HTMLElement | null = null + if (typeof document !== "undefined") { + activeElement = document.activeElement as HTMLElement | null + } + const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']") + const isEditingField = + activeElement && + (["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal)) + + if (isEditingField) { + return + } + const normalizedKey = e.key.toLowerCase() const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n" const blockedKeys = [ diff --git a/packages/ui/src/components/tool-call/markdown-render.tsx b/packages/ui/src/components/tool-call/markdown-render.tsx index 58e356e5..36969901 100644 --- a/packages/ui/src/components/tool-call/markdown-render.tsx +++ b/packages/ui/src/components/tool-call/markdown-render.tsx @@ -23,20 +23,26 @@ export function createMarkdownContentRenderer(params: { const size = options.size || "default" const disableHighlight = options.disableHighlight || false const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` + const disableScrollTracking = options.disableScrollTracking || false const state = params.toolState() const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight) if (shouldDeferMarkdown) { return ( -
params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> +
params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })} + onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} + >
{options.content}
- {params.scrollHelpers.renderSentinel()} + {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
) } + const cacheKey = typeof options.cacheKey === "string" && options.cacheKey.length > 0 ? options.cacheKey : undefined const markdownPart: TextPart = { - id: params.partId(), + id: cacheKey ? `${params.partId()}:${cacheKey}` : params.partId(), type: "text", text: options.content, version: params.partVersion?.(), @@ -48,7 +54,11 @@ export function createMarkdownContentRenderer(params: { } return ( -
params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> +
params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })} + onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} + > - {params.scrollHelpers.renderSentinel()} + {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
) } diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 0ac0ecdd..a7305cb3 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,8 +1,7 @@ import { For, Show, createMemo } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" import type { ToolRenderer } from "../types" -import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" -import { getTodoTitle } from "./todo" +import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { resolveTitleForTool } from "../tool-title" interface TaskSummaryItem { @@ -90,7 +89,51 @@ export const taskRenderer: ToolRenderer = { const { input } = readToolStatePayload(state) return describeTaskTitle(input) }, - renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) { + renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) { + const promptContent = createMemo(() => { + const state = toolState() + if (!state) return null + const { input } = readToolStatePayload(state) + const prompt = typeof input.prompt === "string" ? input.prompt : null + return ensureMarkdownContent(prompt, undefined, false) + }) + + const outputContent = createMemo(() => { + const state = toolState() + if (!state) return null + const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null + return ensureMarkdownContent(output, undefined, false) + }) + + const agentLabel = createMemo(() => { + const state = toolState() + if (!state) return null + const { input } = readToolStatePayload(state) + return typeof input.subagent_type === "string" ? input.subagent_type : null + }) + + const modelLabel = createMemo(() => { + const state = toolState() + if (!state) return null + const { metadata } = readToolStatePayload(state) + const model = (metadata as any).model + if (!model || typeof model !== "object") return null + const providerId = typeof model.providerID === "string" ? model.providerID : null + const modelId = typeof model.modelID === "string" ? model.modelID : null + if (!providerId && !modelId) return null + if (providerId && modelId) return `${providerId}/${modelId}` + return providerId ?? modelId + }) + + const headerMeta = createMemo(() => { + const agent = agentLabel() + const model = modelLabel() + if (agent && model) return `Agent: ${agent} • Model: ${model}` + if (agent) return `Agent: ${agent}` + if (model) return `Model: ${model}` + return null + }) + const items = createMemo(() => { // Track the reactive change points so we only recompute when the part/message changes messageVersion?.() @@ -114,41 +157,90 @@ export const taskRenderer: ToolRenderer = { }) }) - if (items().length === 0) return null - return ( -
scrollHelpers?.registerContainer(element)} - onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined} - > -
- - {(item) => { - const icon = getToolIcon(item.tool) - const description = describeToolTitle(item) - const toolLabel = getToolName(item.tool) - const status = normalizeStatus(item.status ?? item.state?.status) - const statusIcon = summarizeStatusIcon(status) - const statusLabel = summarizeStatusLabel(status) - const statusAttr = status ?? "pending" - return ( -
- {icon} - {toolLabel} - - {description} - - - {statusIcon} - - +
+ +
+
+ Prompt + + + +
+
+ {renderMarkdown({ + content: promptContent()!, + cacheKey: "task:prompt", + disableScrollTracking: true, + disableHighlight: true, + })} +
+
+
+ + 0}> +
+
+ Steps + +
+
+
scrollHelpers?.registerContainer(element)} + onScroll={ + scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined + } + > +
+ + {(item) => { + const icon = getToolIcon(item.tool) + const description = describeToolTitle(item) + const toolLabel = getToolName(item.tool) + const status = normalizeStatus(item.status ?? item.state?.status) + const statusIcon = summarizeStatusIcon(status) + const statusLabel = summarizeStatusLabel(status) + const statusAttr = status ?? "pending" + return ( +
+ {icon} + {toolLabel} + + {description} + + + {statusIcon} + + +
+ ) + }} +
- ) - }} - -
- {scrollHelpers?.renderSentinel?.()} + {scrollHelpers?.renderSentinel?.()} +
+
+ + + + +
+
+ Output + + + +
+
+ {renderMarkdown({ + content: outputContent()!, + cacheKey: "task:output", + disableScrollTracking: true, + })} +
+
+
) }, diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index 22db6903..ca6fbc5c 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -13,6 +13,16 @@ export interface MarkdownRenderOptions { content: string size?: "default" | "large" disableHighlight?: boolean + /** + * Optional suffix to avoid render-cache collisions when a tool call renders + * multiple markdown regions (e.g. task prompt vs task output). + */ + cacheKey?: string + /** + * When true, do not register this markdown region with tool-call scroll + * tracking (avoids nested scroll + autoscroll interactions). + */ + disableScrollTracking?: boolean } export interface AnsiRenderOptions { diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index e6793484..a0e02eaf 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -8,6 +8,7 @@ import type { BinaryUpdateRequest, BinaryValidationResult, FileSystemEntry, + FileSystemCreateFolderResponse, FileSystemListResponse, InstanceData, ServerMeta, @@ -224,6 +225,13 @@ export const serverApi = { const query = params.toString() return request(query ? `/api/filesystem?${query}` : "/api/filesystem") }, + + createFileSystemFolder(parentPath: string | undefined, name: string): Promise { + return request("/api/filesystem/folders", { + method: "POST", + body: JSON.stringify({ parentPath, name }), + }) + }, readInstanceData(id: string): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`) }, diff --git a/packages/ui/src/styles/components/directory-browser.css b/packages/ui/src/styles/components/directory-browser.css index 635d4d7c..bdbccbc0 100644 --- a/packages/ui/src/styles/components/directory-browser.css +++ b/packages/ui/src/styles/components/directory-browser.css @@ -81,6 +81,14 @@ width: auto; } +.directory-browser-current-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; + justify-content: flex-end; +} + .directory-browser-close { display: inline-flex; diff --git a/packages/ui/src/styles/messaging/tool-call/task.css b/packages/ui/src/styles/messaging/tool-call/task.css index 914690c1..2756cca2 100644 --- a/packages/ui/src/styles/messaging/tool-call/task.css +++ b/packages/ui/src/styles/messaging/tool-call/task.css @@ -1,7 +1,74 @@ -.tool-call-task-container { +.tool-call-task-sections { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: 0; +} + +.tool-call-task-section { + border: 1px solid var(--border-base); + overflow: hidden; + background-color: transparent; + border-radius: 0; +} + +.tool-call-task-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem; + background-color: var(--surface-secondary); + border-bottom: 1px solid var(--border-base); + font-family: var(--font-family-mono); + font-size: 13px; + color: inherit; +} + +.tool-call-task-section-title { + font-weight: var(--font-weight-semibold); +} + +.tool-call-task-section-meta { + font-family: var(--font-family-mono); + color: var(--text-muted); +} + +.tool-call-task-section-body { + background-color: var(--surface-code); +} + +.tool-call-task-section-body .tool-call-markdown { padding: 12px; } +.tool-call-task-container { + padding: 0; +} + +/* Steps list should be flush (no inset padding). */ +.tool-call-task-section-body .tool-call-task-container.tool-call-markdown { + padding: 0; +} + +/* Keep task lists compact vs prompt/output panes. */ +.tool-call-task-container.tool-call-markdown { + max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) / 2); +} + +/* Prompt + output panes: slightly taller than tasks. */ +.tool-call-task-section-body > .tool-call-markdown:not(.tool-call-task-container) { + max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) * 2 / 3); +} + +.tool-call-task-empty { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--line-height-tight); + color: var(--text-muted); + padding: 0.5rem; +} + .tool-call-task-summary { display: flex; flex-direction: column; diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 2d256fa3..b7609c9a 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -103,6 +103,25 @@ box-shadow: 0 0 0 2px var(--accent-primary); } +/* Form controls */ +.form-input { + @apply w-full px-3 py-2 text-sm; + background-color: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + color: var(--text-primary); +} + +.form-input::placeholder { + color: var(--text-muted); +} + +.form-input:focus { + outline: none; + border-color: transparent; + box-shadow: 0 0 0 2px var(--accent-primary); +} + /* Shared animations */ @keyframes pulse { 0%, 100% { opacity: 1; }