diff --git a/src/cli.ts b/src/cli.ts index 5ac9eef6b..b66f4d4f1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,7 +39,7 @@ import { loadEffectiveSFPreferences } from './resources/extensions/sf/preference // eliminating repeated parse/compile overhead for unchanged modules. // Must be set early so dynamic imports (extensions, lazy subcommands) benefit. // --------------------------------------------------------------------------- -if (parseInt(process.versions.node) >= 22) { +if (parseInt(process.versions.node.split('.')[0], 10) >= 22) { process.env.NODE_COMPILE_CACHE ??= join(agentDir, '.compile-cache') } diff --git a/src/resources/extensions/sf/files.ts b/src/resources/extensions/sf/files.ts index 3bfceb4d9..34f028fcc 100644 --- a/src/resources/extensions/sf/files.ts +++ b/src/resources/extensions/sf/files.ts @@ -390,9 +390,9 @@ function _parseContinueImpl(content: string): Continue { milestone: (fm.milestone as string) || '', slice: (fm.slice as string) || '', task: (fm.task as string) || '', - step: typeof fm.step === 'string' ? parseInt(fm.step) : (fm.step as number) || 0, - totalSteps: typeof fm.total_steps === 'string' ? parseInt(fm.total_steps) : (fm.total_steps as number) || - (typeof fm.totalSteps === 'string' ? parseInt(fm.totalSteps) : (fm.totalSteps as number) || 0), + step: typeof fm.step === 'string' ? parseInt(fm.step, 10) || 0 : (fm.step as number) || 0, + totalSteps: typeof fm.total_steps === 'string' ? parseInt(fm.total_steps, 10) || 0 : (fm.total_steps as number) || + (typeof fm.totalSteps === 'string' ? parseInt(fm.totalSteps, 10) || 0 : (fm.totalSteps as number) || 0), status: ((fm.status as string) || 'in_progress') as ContinueStatus, savedAt: (fm.saved_at as string) || (fm.savedAt as string) || '', }; diff --git a/src/resources/extensions/sf/forensics.ts b/src/resources/extensions/sf/forensics.ts index 2eeb0c56d..5cf71b9b7 100644 --- a/src/resources/extensions/sf/forensics.ts +++ b/src/resources/extensions/sf/forensics.ts @@ -11,6 +11,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent"; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join, dirname, relative } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; @@ -1028,7 +1029,7 @@ export function writeForensicsMarker(basePath: string, reportPath: string, promp promptContent, createdAt: new Date().toISOString(), }; - writeFileSync(join(dir, "active-forensics.json"), JSON.stringify(marker), "utf-8"); + atomicWriteSync(join(dir, "active-forensics.json"), JSON.stringify(marker)); } /** diff --git a/src/resources/extensions/sf/phase-anchor.ts b/src/resources/extensions/sf/phase-anchor.ts index 5aaadcec6..412dbb02c 100644 --- a/src/resources/extensions/sf/phase-anchor.ts +++ b/src/resources/extensions/sf/phase-anchor.ts @@ -4,8 +4,9 @@ * and intent without re-inferring from scratch. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; import { sfRoot } from "./paths.js"; export interface PhaseAnchor { @@ -31,7 +32,7 @@ export function writePhaseAnchor(basePath: string, milestoneId: string, anchor: if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(anchorPath(basePath, milestoneId, anchor.phase), JSON.stringify(anchor, null, 2), "utf-8"); + atomicWriteSync(anchorPath(basePath, milestoneId, anchor.phase), JSON.stringify(anchor, null, 2)); } export function readPhaseAnchor(basePath: string, milestoneId: string, phase: string): PhaseAnchor | null { diff --git a/src/resources/extensions/sf/slice-parallel-orchestrator.ts b/src/resources/extensions/sf/slice-parallel-orchestrator.ts index eaeff5e67..f8cdf08ee 100644 --- a/src/resources/extensions/sf/slice-parallel-orchestrator.ts +++ b/src/resources/extensions/sf/slice-parallel-orchestrator.ts @@ -33,6 +33,7 @@ import { import { hasFileConflict } from "./slice-parallel-conflict.js"; import { getErrorMessage } from "./error-utils.js"; import { selectConflictFreeBatch } from "./uok/execution-graph.js"; +import { logWarning } from "./workflow-logger.js"; // ─── Types ───────────────────────────────────────────────────────────────── @@ -343,7 +344,8 @@ function spawnSliceWorker( stdio: ["ignore", "pipe", "pipe"], detached: false, }); - } catch { + } catch (e) { + logWarning("parallel", `slice worker spawn failed: ${(e as Error).message}`); return false; } diff --git a/src/resources/extensions/sf/visualizer-data.ts b/src/resources/extensions/sf/visualizer-data.ts index 20e32a3bc..3b0311ece 100644 --- a/src/resources/extensions/sf/visualizer-data.ts +++ b/src/resources/extensions/sf/visualizer-data.ts @@ -638,13 +638,18 @@ function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null, basePath environmentIssues = runEnvironmentChecks(basePath).filter(r => r.status !== "ok"); } catch { /* non-fatal */ } - // Doctor run history — persisted across sessions (sync read to keep loadHealth sync) + // Doctor run history — persisted across sessions (sync read to keep loadHealth sync). + // Parse each line independently so a single corrupt row doesn't discard the + // surrounding 19 valid entries. let doctorHistory: VisualizerDoctorEntry[] = []; try { const historyPath = join(sfRoot(basePath), "doctor-history.jsonl"); if (existsSync(historyPath)) { const lines = readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim()); - doctorHistory = lines.slice(-20).reverse().map(l => JSON.parse(l) as VisualizerDoctorEntry); + doctorHistory = lines.slice(-20).reverse().flatMap(l => { + try { return [JSON.parse(l) as VisualizerDoctorEntry]; } + catch { return []; } + }); } } catch { /* non-fatal */ } diff --git a/src/web/auto-dashboard-service.ts b/src/web/auto-dashboard-service.ts index b7570d253..67b2e656e 100644 --- a/src/web/auto-dashboard-service.ts +++ b/src/web/auto-dashboard-service.ts @@ -73,15 +73,18 @@ function reconcileWithDiskState( if (data.active || data.paused) return data; // Check for paused-session.json first (paused takes precedence). + // The file's *existence* is the signal — content is informational. If the + // content is corrupt, still report paused (safer than silently showing active). const pausedPath = join(projectCwd, ".sf", "runtime", "paused-session.json"); if (checkExists(pausedPath)) { try { - // Validate the file is readable JSON (not corrupt). JSON.parse(readFileSync(pausedPath, "utf-8")); - return { ...data, paused: true }; - } catch { - // Corrupt or unreadable — ignore. + } catch (err) { + console.warn( + `[auto-dashboard] paused-session.json corrupt: ${err instanceof Error ? err.message : String(err)} — still reporting paused`, + ); } + return { ...data, paused: true }; } // Check for session lock with a live PID. diff --git a/web/components/sf/tempCodeRunnerFile.tsx b/web/components/sf/tempCodeRunnerFile.tsx deleted file mode 100644 index 4fd6a856c..000000000 --- a/web/components/sf/tempCodeRunnerFile.tsx +++ /dev/null @@ -1,784 +0,0 @@ -"use client" - -import { useEffect, useRef, useCallback, useState } from "react" -import { useTheme } from "next-themes" -import { Plus, X, TerminalSquare, Loader2, ImagePlus } from "lucide-react" -import { cn } from "@/lib/utils" -import { validateImageFile } from "@/lib/image-utils" -import { filterInitialSfHeader } from "@/lib/initial-sf-header-filter" -import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url" -import { authFetch, appendAuthParam } from "@/lib/auth" -import "@xterm/xterm/css/xterm.css" - -type XTerminal = import("@xterm/xterm").Terminal -type XFitAddon = import("@xterm/addon-fit").FitAddon - -const MIN_TERMINAL_ATTACH_WIDTH = 180 -const MIN_TERMINAL_ATTACH_HEIGHT = 120 -const MIN_TERMINAL_ATTACH_COLS = 20 -const MIN_TERMINAL_ATTACH_ROWS = 8 - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface TerminalTab { - id: string - label: string - connected: boolean -} - -interface ShellTerminalProps { - className?: string - command?: string - commandArgs?: string[] - sessionPrefix?: string - hideSidebar?: boolean - fontSize?: number - hideInitialSfHeader?: boolean - projectCwd?: string -} - -// ─── xterm themes ───────────────────────────────────────────────────────────── - -const XTERM_DARK_THEME = { - background: "#0a0a0a", - foreground: "#e4e4e7", - cursor: "#e4e4e7", - cursorAccent: "#0a0a0a", - selectionBackground: "#27272a", - selectionForeground: "#e4e4e7", - black: "#18181b", - red: "#ef4444", - green: "#22c55e", - yellow: "#eab308", - blue: "#3b82f6", - magenta: "#a855f7", - cyan: "#06b6d4", - white: "#e4e4e7", - brightBlack: "#52525b", - brightRed: "#f87171", - brightGreen: "#4ade80", - brightYellow: "#facc15", - brightBlue: "#60a5fa", - brightMagenta: "#c084fc", - brightCyan: "#22d3ee", - brightWhite: "#fafafa", -} as const - -const XTERM_LIGHT_THEME = { - background: "#f5f5f5", - foreground: "#1a1a1a", - cursor: "#1a1a1a", - cursorAccent: "#f5f5f5", - selectionBackground: "#d4d4d8", - selectionForeground: "#1a1a1a", - black: "#1a1a1a", - red: "#dc2626", - green: "#16a34a", - yellow: "#ca8a04", - blue: "#2563eb", - magenta: "#9333ea", - cyan: "#0891b2", - white: "#e4e4e7", - brightBlack: "#71717a", - brightRed: "#ef4444", - brightGreen: "#22c55e", - brightYellow: "#eab308", - brightBlue: "#3b82f6", - brightMagenta: "#a855f7", - brightCyan: "#06b6d4", - brightWhite: "#fafafa", -} as const - -function getXtermTheme(isDark: boolean) { - return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME -} - -function getXtermOptions(isDark: boolean, fontSize?: number) { - return { - cursorBlink: true, - cursorStyle: "bar" as const, - fontSize: fontSize ?? 13, - fontFamily: - "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, 'Courier New', monospace", - lineHeight: 1.35, - letterSpacing: 0, - theme: getXtermTheme(isDark), - allowProposedApi: true, - scrollback: 10000, - convertEol: false, - } -} - -function getRenderableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null { - if (!container || !terminal) return null - - const rect = container.getBoundingClientRect() - if (rect.width < MIN_TERMINAL_ATTACH_WIDTH || rect.height < MIN_TERMINAL_ATTACH_HEIGHT) { - return null - } - - if (terminal.cols < MIN_TERMINAL_ATTACH_COLS || terminal.rows < MIN_TERMINAL_ATTACH_ROWS) { - return null - } - - return { cols: terminal.cols, rows: terminal.rows } -} - -async function settleTerminalLayout( - container: HTMLDivElement | null, - terminal: XTerminal | null, - fitAddon: XFitAddon | null, - isDisposed: () => boolean, -): Promise<{ cols: number; rows: number } | null> { - if (typeof document !== "undefined" && "fonts" in document) { - try { - await Promise.race([ - document.fonts.ready, - new Promise((resolve) => setTimeout(resolve, 1000)), - ]) - } catch { - // Ignore font loading failures and fall through to repeated fit attempts. - } - } - - for (let attempt = 0; attempt < 12; attempt++) { - if (isDisposed()) return null - - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()) - }) - - if (isDisposed()) return null - - try { - fitAddon?.fit() - } catch { - /* hidden or detached */ - } - - const size = getRenderableTerminalSize(container, terminal) - if (size) { - return size - } - - await new Promise((resolve) => setTimeout(resolve, 50)) - } - - return getRenderableTerminalSize(container, terminal) -} - -function deriveCommandLabel(command?: string): string { - if (!command?.trim()) return "zsh" - const token = command.trim().split(/\s+/)[0] || command - const normalized = token.replace(/\\/g, "/") - const parts = normalized.split("/") - return parts[parts.length - 1] || token -} - -// ─── Single terminal instance (internal) ────────────────────────────────────── - -interface TerminalInstanceProps { - sessionId: string - visible: boolean - command?: string - commandArgs?: string[] - isDark: boolean - fontSize?: number - hideInitialSfHeader?: boolean - projectCwd?: string - onConnectionChange: (connected: boolean) => void -} - -function TerminalInstance({ - sessionId, - visible, - command, - commandArgs, - isDark, - fontSize, - hideInitialSfHeader = false, - projectCwd, - onConnectionChange, -}: TerminalInstanceProps) { - const containerRef = useRef(null) - const termRef = useRef(null) - const fitAddonRef = useRef(null) - const eventSourceRef = useRef(null) - const inputQueueRef = useRef([]) - const flushingRef = useRef(false) - const resizeTimeoutRef = useRef | null>(null) - const onConnectionChangeRef = useRef(onConnectionChange) - const initialHeaderSettledRef = useRef(!hideInitialSfHeader) - const initialHeaderBufferRef = useRef("") - const commandArgsKey = (commandArgs ?? []).join("\u0000") - const [hasOutput, setHasOutput] = useState(false) - - const sendResize = useCallback( - (cols: number, rows: number) => { - if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current) - resizeTimeoutRef.current = setTimeout(() => { - void authFetch(buildProjectPath("/api/terminal/resize", projectCwd), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: sessionId, cols, rows }), - }) - }, 100) - }, - [projectCwd, sessionId], - ) - - const flushInputQueue = useCallback(async () => { - if (flushingRef.current) return - flushingRef.current = true - while (inputQueueRef.current.length > 0) { - const data = inputQueueRef.current.shift()! - try { - await authFetch(buildProjectPath("/api/terminal/input", projectCwd), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: sessionId, data }), - }) - } catch { - inputQueueRef.current.unshift(data) - break - } - } - flushingRef.current = false - }, [projectCwd, sessionId]) - - const sendInput = useCallback( - (data: string) => { - inputQueueRef.current.push(data) - void flushInputQueue() - }, - [flushInputQueue], - ) - - useEffect(() => { - onConnectionChangeRef.current = onConnectionChange - }, [onConnectionChange]) - - useEffect(() => { - initialHeaderSettledRef.current = !hideInitialSfHeader - initialHeaderBufferRef.current = "" - }, [hideInitialSfHeader, sessionId]) - - // Update xterm theme when isDark changes - useEffect(() => { - if (termRef.current) { - termRef.current.options.theme = getXtermTheme(isDark) - } - }, [isDark]) - - // Update xterm font size when fontSize changes - useEffect(() => { - if (termRef.current) { - termRef.current.options.fontSize = fontSize ?? 13 - try { - fitAddonRef.current?.fit() - if (termRef.current) { - sendResize(termRef.current.cols, termRef.current.rows) - } - } catch { - /* not visible yet */ - } - } - }, [fontSize, sendResize]) - - // Re-fit when visibility changes - useEffect(() => { - if (visible && fitAddonRef.current && termRef.current) { - // Small delay to let the DOM settle - const t = setTimeout(() => { - try { - fitAddonRef.current?.fit() - if (termRef.current) { - sendResize(termRef.current.cols, termRef.current.rows) - } - } catch { - /* not visible yet */ - } - }, 50) - return () => clearTimeout(t) - } - }, [visible, sendResize]) - - useEffect(() => { - if (!containerRef.current) return - - let disposed = false - let terminal: XTerminal | null = null - let fitAddon: XFitAddon | null = null - let resizeObserver: ResizeObserver | null = null - - const init = async () => { - const [{ Terminal }, { FitAddon }] = await Promise.all([ - import("@xterm/xterm"), - import("@xterm/addon-fit"), - ]) - - if (disposed) return - - terminal = new Terminal(getXtermOptions(isDark, fontSize)) - fitAddon = new FitAddon() - terminal.loadAddon(fitAddon) - terminal.open(containerRef.current!) - - termRef.current = terminal - fitAddonRef.current = fitAddon - - await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed) - if (disposed) return - - terminal.onData((data) => sendInput(data)) - terminal.onBinary((data) => sendInput(data)) - - // SSE stream - const streamUrl = buildProjectAbsoluteUrl( - "/api/terminal/stream", - window.location.origin, - projectCwd, - ) - streamUrl.searchParams.set("id", sessionId) - if (command) streamUrl.searchParams.set("command", command) - for (const arg of commandArgs ?? []) { - streamUrl.searchParams.append("arg", arg) - } - const es = new EventSource(appendAuthParam(streamUrl.toString())) - eventSourceRef.current = es - - es.onmessage = (event) => { - try { - const msg = JSON.parse(event.data) as { - type: string - data?: string - } - if (msg.type === "connected") { - onConnectionChangeRef.current(true) - void settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed).then((size) => { - if (!size) return - sendResize(size.cols, size.rows) - }) - } else if (msg.type === "output" && msg.data) { - let output = msg.data - - if (hideInitialSfHeader && !initialHeaderSettledRef.current) { - initialHeaderBufferRef.current += output - const filtered = filterInitialSfHeader(initialHeaderBufferRef.current) - - if (filtered.status === "needs-more") { - return - } - - initialHeaderSettledRef.current = true - initialHeaderBufferRef.current = "" - output = filtered.text - } - - if (output) { - terminal?.write(output) - setHasOutput(true) - } - } - } catch { - /* malformed */ - } - } - - es.onerror = () => onConnectionChangeRef.current(false) - - // Resize observer - resizeObserver = new ResizeObserver(() => { - if (disposed) return - try { - fitAddon?.fit() - if (terminal) sendResize(terminal.cols, terminal.rows) - } catch { - /* not visible */ - } - }) - resizeObserver.observe(containerRef.current!) - } - - void init() - - return () => { - disposed = true - if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current) - eventSourceRef.current?.close() - eventSourceRef.current = null - resizeObserver?.disconnect() - terminal?.dispose() - termRef.current = null - fitAddonRef.current = null - } - }, [sessionId, command, commandArgs, commandArgsKey, fontSize, hideInitialSfHeader, isDark, projectCwd, sendInput, sendResize]) - - // Focus on click - const wrapperRef = useRef(null) - const handleClick = useCallback(() => { - termRef.current?.focus() - }, []) - - // Shift+Enter → newline (native DOM, capture phase) - // xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor - // recognizes \n (LF) as "insert newline". - useEffect(() => { - const el = wrapperRef.current - if (!el) return - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { - e.preventDefault() - e.stopPropagation() - sendInput("\n") - } - } - - el.addEventListener("keydown", onKeyDown, true) - return () => el.removeEventListener("keydown", onKeyDown, true) - }, [sendInput]) - - // Auto-focus when this tab becomes visible - useEffect(() => { - if (visible) { - // Small delay to let layout settle - const t = setTimeout(() => termRef.current?.focus(), 80) - return () => clearTimeout(t) - } - }, [visible]) - - return ( -
- {/* Loading overlay — visible until first output arrives */} - {!hasOutput && ( -
- - - {command ? "Starting SF…" : "Connecting…"} - -
- )} -
-
- ) -} - -// ─── Image upload helpers ───────────────────────────────────────────────────── - -const ALLOWED_IMAGE_TYPES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]) - -/** - * Upload an image file to the server's temp directory and inject the `@filepath` - * text into the PTY session's stdin. - * - * Observability: - * - console.warn on client-side validation failure - * - console.error on upload or inject failure - */ -async function uploadAndInjectImage(file: File, sessionId: string, projectCwd?: string): Promise { - // Client-side validation - const validation = validateImageFile(file) - if (!validation.valid) { - console.warn("[terminal-upload] validation failed:", validation.error) - return - } - - // Upload to temp dir - const formData = new FormData() - formData.append("file", file) - - let uploadPath: string - try { - const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), { - method: "POST", - body: formData, - }) - const data = await res.json() as { ok?: boolean; path?: string; error?: string } - if (!res.ok || !data.path) { - console.error("[terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`) - return - } - uploadPath = data.path - } catch (err) { - console.error("[terminal-upload] upload request failed:", err) - return - } - - // Inject @filepath into PTY stdin - try { - const res = await authFetch(buildProjectPath("/api/terminal/input", projectCwd), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: sessionId, data: `@${uploadPath} ` }), - }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) as { error?: string } - console.error("[terminal-upload] inject failed:", data.error ?? `HTTP ${res.status}`) - } - } catch (err) { - console.error("[terminal-upload] inject request failed:", err) - } -} - -// ─── Multi-instance terminal panel ──────────────────────────────────────────── - -export function ShellTerminal({ - className, - command, - commandArgs, - sessionPrefix, - hideSidebar = false, - fontSize, - hideInitialSfHeader = false, - projectCwd, -}: ShellTerminalProps) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme !== "light" - const defaultId = sessionPrefix ?? (command ? "sf-default" : "default") - const commandLabel = deriveCommandLabel(command) - const [tabs, setTabs] = useState([ - { id: defaultId, label: commandLabel, connected: false }, - ]) - const [activeTabId, setActiveTabId] = useState(defaultId) - const [isDragOver, setIsDragOver] = useState(false) - const terminalAreaRef = useRef(null) - - // ── Drag-and-drop handlers (native DOM, capture phase) ────────────────── - // React synthetic events don't reliably fire through xterm's internal DOM. - // Native capture-phase listeners intercept before xterm can swallow them — - // same pattern used for paste below. - - useEffect(() => { - const el = terminalAreaRef.current - if (!el) return - - let counter = 0 - - const onDragEnter = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - counter += 1 - if (counter === 1) setIsDragOver(true) - } - - const onDragOver = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const onDragLeave = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - counter -= 1 - if (counter <= 0) { - counter = 0 - setIsDragOver(false) - } - } - - const onDrop = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - counter = 0 - setIsDragOver(false) - - if (!activeTabId) return - const files = Array.from(e.dataTransfer?.files ?? []) - const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type)) - if (imageFile) { - void uploadAndInjectImage(imageFile, activeTabId, projectCwd) - } - } - - el.addEventListener("dragenter", onDragEnter, true) - el.addEventListener("dragover", onDragOver, true) - el.addEventListener("dragleave", onDragLeave, true) - el.addEventListener("drop", onDrop, true) - return () => { - el.removeEventListener("dragenter", onDragEnter, true) - el.removeEventListener("dragover", onDragOver, true) - el.removeEventListener("dragleave", onDragLeave, true) - el.removeEventListener("drop", onDrop, true) - } - }, [activeTabId, projectCwd]) - - // ── Paste handler for images ────────────────────────────────────────────── - - useEffect(() => { - const el = terminalAreaRef.current - if (!el) return - - const handlePaste = (e: ClipboardEvent) => { - if (!e.clipboardData) return - const files = Array.from(e.clipboardData.files) - const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type)) - if (imageFile) { - e.preventDefault() - e.stopPropagation() - if (activeTabId) { - void uploadAndInjectImage(imageFile, activeTabId, projectCwd) - } - } - // If no image files, don't prevent default — let xterm.js handle text paste - } - - el.addEventListener("paste", handlePaste, true) // capture phase to fire before xterm - return () => el.removeEventListener("paste", handlePaste, true) - }, [activeTabId, projectCwd]) - - const createTab = useCallback(async () => { - try { - const res = await authFetch(buildProjectPath("/api/terminal/sessions", projectCwd), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(command ? { command } : {}), - }) - const data = (await res.json()) as { id: string } - const newTab: TerminalTab = { - id: data.id, - label: commandLabel, - connected: false, - } - setTabs((prev) => [...prev, newTab]) - setActiveTabId(data.id) - } catch { - /* network error */ - } - }, [command, commandLabel, projectCwd]) - - const closeTab = useCallback( - (id: string) => { - // Don't close the last tab - if (tabs.length <= 1) return - const deleteUrl = buildProjectAbsoluteUrl("/api/terminal/sessions", window.location.origin, projectCwd) - deleteUrl.searchParams.set("id", id) - void authFetch(deleteUrl.toString(), { - method: "DELETE", - }) - const remaining = tabs.filter((t) => t.id !== id) - setTabs(remaining) - if (activeTabId === id) { - setActiveTabId(remaining[remaining.length - 1]?.id ?? defaultId) - } - }, - [tabs, activeTabId, defaultId, projectCwd], - ) - - const updateConnection = useCallback( - (id: string, connected: boolean) => { - setTabs((prev) => - prev.map((t) => (t.id === id ? { ...t, connected } : t)), - ) - }, - [], - ) - - return ( -
- {/* Terminal area — receives drag/drop and paste for images */} -
- {tabs.map((tab) => ( - updateConnection(tab.id, c)} - /> - ))} - - {/* Drop overlay */} - {isDragOver && ( -
- - Drop image here -
- )} -
- - {!hideSidebar && ( -
- {/* New terminal button */} - - -
- - {/* Tab list */} -
- {tabs.map((tab, index) => ( - - )} - - ))} -
-
- )} -
- ) -}