diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index b53440cb..a43f2aa4 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -466,6 +466,14 @@ Frontend `console.log/warn/error` calls land in the Terminal pane under stream as `[BACKEND]` lines, so you can correlate cause and effect across the two halves of your stack. +**Read the terminal yourself: `.openswarm/terminal.log`** at the workspace +root is a live tee of everything the Terminal pane shows — `[BACKEND]` / +`[BACKEND:stderr]` stdout+stderr, `[RUNTIME]` events, and `[FRONTEND]` / +`[FRONTEND:warn]` / `[FRONTEND:error]` console lines from the running app. +It resets on every app (re)start. When something misbehaves, don't guess — +`tail -100 .openswarm/terminal.log` (or grep it for `error`) and look at +what actually happened. + --- ## Adding npm packages @@ -514,8 +522,8 @@ runtime errors as a visible red error card AND mirrors the error into the Terminal pane as a `[FRONTEND]` line tagged `[openswarm:app-error]`. After substantial edits — especially anything that touches imports, hooks, or React state — **always check the most recent `[FRONTEND]` -lines in your Terminal output before saying "done"**. If you see one, -fix it before claiming the app is ready. +lines before saying "done"** (`tail -50 .openswarm/terminal.log`). +If you see one, fix it before claiming the app is ready. The three most common ways agent edits crash a React preview: diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 3e0a06d1..edc58f0b 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -439,6 +439,24 @@ async def runtime_report_error(workspace_id: str, body: dict): return {"ok": True, "recorded": 1} +@outputs.router.post("/workspace/{workspace_id}/runtime/console-log") +async def runtime_console_log(workspace_id: str, body: dict): + """Fold webview console lines into the runtime's terminal stream so they reach the Terminal panes AND the agent-readable .openswarm/terminal.log. Renderer batches; body is {lines: [{level, text}, ...]}.""" + from backend.apps.outputs.runtime import manager as runtime_manager + rt = runtime_manager.get(workspace_id) + if rt is None: + return {"ok": False, "recorded": 0} + lines = body.get("lines") or [] + recorded = 0 + for entry in lines[:200]: + text = str(entry.get("text") or "").strip() + if not text: + continue + rt.record_frontend_log(str(entry.get("level") or "log"), text) + recorded += 1 + return {"ok": True, "recorded": recorded} + + @outputs.router.post("/workspace/{workspace_id}/runtime/report-ready") async def runtime_report_ready(workspace_id: str): from backend.apps.outputs.runtime import manager as runtime_manager diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 19c4f91c..4e5850f1 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -52,10 +52,24 @@ p_vite_boot_lock = asyncio.Lock() @dataclass class LogLine: - stream: str # "stdout" | "stderr" | "runtime" (internal status lines) + stream: str # "stdout" | "stderr" | "runtime" (internal) | "frontend[-warn|-error]" (webview console via the console-log beacon) text: str +# Byte cap for the on-disk terminal tee; past this we rewrite the file from the ring buffer so an HMR-spammy session can't grow it unbounded. +TERMINAL_LOG_MAX_BYTES = 4 * 1024 * 1024 + +# Human/agent-facing prefixes for the terminal.log tee; mirrors the Terminal pane's labels so skill docs describe both with one vocabulary. +TERMINAL_LOG_PREFIXES = { + "stdout": "[BACKEND]", + "stderr": "[BACKEND:stderr]", + "runtime": "[RUNTIME]", + "frontend": "[FRONTEND]", + "frontend-warn": "[FRONTEND:warn]", + "frontend-error": "[FRONTEND:error]", +} + + LogSubscriber = Callable[[LogLine], None] @@ -83,6 +97,9 @@ class AppRuntime: self.p_suspended: bool = False self.process: Optional[asyncio.subprocess.Process] = None self.log_buffer: deque[LogLine] = deque(maxlen=LOG_BUFFER_LINES) + # On-disk tee of the ring buffer so the App Builder agent can inspect terminal output itself (Read/grep); reset on every start(). + self.p_terminal_log_path = os.path.join(workspace_path, ".openswarm", "terminal.log") + self.p_terminal_log_bytes = 0 self.p_subscribers: set[LogSubscriber] = set() # Recent build/runtime errors scraped from stderr; drained by the agent's post-tool hook after Write/Edit so the agent sees vite/babel/uvicorn errors in its next turn and can self-fix instead of leaving the user with a red iframe overlay. self.recent_errors: deque[str] = deque(maxlen=RECENT_ERRORS_MAX) @@ -160,6 +177,7 @@ class AppRuntime: if self.running: return True + self.p_reset_terminal_log() if self.is_new_mode: # Acquire the module-level boot lock BEFORE the spawn so only one new-mode workspace is mid-bundle at a time. The lock is released by the bind-poll task the moment vite emits "frontend ready" (or its 180s timeout fires), which is the moment the next workspace can start its own vite without competing for the same CPU. See `p_await_frontend_bind` for the release. await p_vite_boot_lock.acquire() @@ -426,6 +444,7 @@ class AppRuntime: def p_broadcast(self, line: LogLine) -> None: self.log_buffer.append(line) + self.p_append_terminal_log(line) # Snapshot subscribers; they can self-remove during dispatch. for cb in list(self.p_subscribers): try: @@ -433,6 +452,38 @@ class AppRuntime: except Exception: pass + def record_frontend_log(self, level: str, text: str) -> None: + """Fold a webview console line into the terminal stream (ring buffer, WS subscribers, terminal.log). Called by the console-log beacon endpoint.""" + stream = {"warn": "frontend-warn", "error": "frontend-error"}.get(level, "frontend") + self.p_broadcast(LogLine(stream, text)) + + def p_reset_terminal_log(self) -> None: + try: + os.makedirs(os.path.dirname(self.p_terminal_log_path), exist_ok=True) + with open(self.p_terminal_log_path, "w", encoding="utf-8") as f: + f.write("# App terminal output (backend stdout/stderr, runtime events, frontend console). Reset on every app start.\n") + self.p_terminal_log_bytes = 0 + except Exception: + logger.exception("terminal.log reset failed for %s", self.workspace_id) + + def p_append_terminal_log(self, line: LogLine) -> None: + # Failures must never break the log pipeline; the file is a convenience tee. + try: + prefix = TERMINAL_LOG_PREFIXES.get(line.stream, f"[{line.stream}]") + rendered = f"{prefix} {line.text}\n" + if self.p_terminal_log_bytes > TERMINAL_LOG_MAX_BYTES: + # Rewrite from the ring buffer so the file self-heals to the last LOG_BUFFER_LINES lines instead of growing unbounded. + with open(self.p_terminal_log_path, "w", encoding="utf-8") as f: + for old in list(self.log_buffer): + f.write(f"{TERMINAL_LOG_PREFIXES.get(old.stream, f'[{old.stream}]')} {old.text}\n") + self.p_terminal_log_bytes = os.path.getsize(self.p_terminal_log_path) + return + with open(self.p_terminal_log_path, "a", encoding="utf-8") as f: + f.write(rendered) + self.p_terminal_log_bytes += len(rendered.encode("utf-8", errors="replace")) + except Exception: + pass + def p_maybe_capture_error(self, text: str) -> None: if ERROR_PATTERNS.search(text): self.recent_errors.append(text.rstrip()) diff --git a/backend/apps/outputs/swarm_debug_skill.md b/backend/apps/outputs/swarm_debug_skill.md index 1f70102a..84e34ddb 100644 --- a/backend/apps/outputs/swarm_debug_skill.md +++ b/backend/apps/outputs/swarm_debug_skill.md @@ -182,6 +182,10 @@ pane in real time. Frontend `console.log` calls in the running app land in the same Terminal pane prefixed `[FRONTEND]`. Use this to correlate cause and effect across the two halves of your stack. +The same stream is tee'd to **`.openswarm/terminal.log`** at the workspace +root (reset on every app start), so you can read your own `debug()` output +directly: `tail -100 .openswarm/terminal.log`. + --- ## Quick reference diff --git a/backend/apps/outputs/webapp_template/.gitignore b/backend/apps/outputs/webapp_template/.gitignore index 637a3dbc..1a9c4b2a 100644 --- a/backend/apps/outputs/webapp_template/.gitignore +++ b/backend/apps/outputs/webapp_template/.gitignore @@ -6,3 +6,4 @@ __pycache__/ *.pyc dist/ build/ +.openswarm/ diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index a398503b..df541770 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -9,6 +9,9 @@ import RefreshIcon from '@mui/icons-material/Refresh'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; import CloseIcon from '@mui/icons-material/Close'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; +import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import TerminalRoundedIcon from '@mui/icons-material/TerminalRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; @@ -16,12 +19,20 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { API_BASE, getAuthToken } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview'; +import TerminalPanel, { TerminalLine } from '@/app/pages/Views/TerminalPanel'; +import AppCodePanel from '@/app/pages/Views/AppCodePanel'; import { getDefault } from '@/shared/inputSchemaDefaults'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useRuntimePreviewUrl, pickPreviewUrl, + RuntimeLogLine, } from '@/shared/hooks/useRuntimePreviewUrl'; +import { postAppConsoleLine, terminalLineFromStream } from '@/shared/appTerminal'; + +type AppCardView = 'preview' | 'code' | 'terminal'; + +const TERMINAL_BUFFER_CAP = 5000; type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; @@ -133,6 +144,20 @@ const DashboardViewCard: React.FC = ({ const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); + // Preview/Code/Terminal switcher; only new-mode (workspace-backed) apps have code + terminal to show. + const [activeView, setActiveView] = useState('preview'); + const hasWorkspace = !!output.workspace_id; + const [terminalLines, setTerminalLines] = useState([]); + const terminalLineIdRef = useRef(0); + // Fed by the runtime logs WS (which replays its ring buffer on connect); frontend console lines arrive on the same socket via the console-log beacon echo. + const handleRuntimeLog = useCallback((line: RuntimeLogLine) => { + const fields = terminalLineFromStream(line.stream, line.text); + setTerminalLines((prev) => { + const next = prev.concat({ id: ++terminalLineIdRef.current, ...fields }); + return next.length > TERMINAL_BUFFER_CAP ? next.slice(next.length - TERMINAL_BUFFER_CAP) : next; + }); + }, []); + // Reload the preview when the session finishes a turn: React holds the ErrorBoundary's snag page until a reload, so without this the user keeps seeing the old error even after the agent fixed it. The overlay lingers through the reload (finishing) so the stale page never flashes. const linkedStatus = useAppSelector( (s) => (output.session_id ? s.agents.sessions[output.session_id]?.status : undefined), @@ -308,11 +333,6 @@ const DashboardViewCard: React.FC = ({ void removeViewCardCleanly(output.id, dispatch); }; - const handleRefresh = (e: React.MouseEvent) => { - e.stopPropagation(); - previewRef.current?.reload(); - }; - const [reloadMenuRect, setReloadMenuRect] = useState(null); const handleHardReload = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); @@ -332,6 +352,16 @@ const DashboardViewCard: React.FC = ({ previewRef.current?.reload(); }, [output.workspace_id]); + // In Terminal view a soft webview reload is invisible (the terminal is what you're looking at), so the refresh button always hard-reloads there. + const handleRefresh = (e: React.MouseEvent) => { + e.stopPropagation(); + if (activeView === 'terminal' && output.workspace_id) { + void handleHardReload(e); + return; + } + previewRef.current?.reload(); + }; + const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx); @@ -447,7 +477,47 @@ const DashboardViewCard: React.FC = ({ {output.name} - + {hasWorkspace && ( + e.stopPropagation()} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.25, + bgcolor: c.bg.page, + borderRadius: 999, + p: 0.25, + flexShrink: 0, + }} + > + {([ + { view: 'preview' as const, label: 'Preview', Icon: VisibilityRoundedIcon }, + { view: 'code' as const, label: 'Code', Icon: CodeRoundedIcon }, + { view: 'terminal' as const, label: 'Terminal', Icon: TerminalRoundedIcon }, + ]).map(({ view, label, Icon }) => ( + + { e.stopPropagation(); setActiveView(view); }} + sx={{ + p: 0.5, + borderRadius: 999, + color: activeView === view ? c.text.primary : c.text.ghost, + bgcolor: activeView === view ? c.bg.elevated : 'transparent', + '&:hover': { color: c.text.primary, bgcolor: activeView === view ? c.bg.elevated : `${c.text.primary}0a` }, + }} + > + + + + ))} + + )} + + = ({ backendResult={backendResult} interactive={interactive} onAppClicked={() => dispatch(setActiveViewCardId(output.id))} + onRuntimeLog={handleRuntimeLog} /> - + {/* Code/Terminal overlay the always-mounted preview instead of replacing it: unmounting the webview kills the app's live state and forces a reload on switch-back. */} + {output.workspace_id && activeView !== 'preview' && ( + + {activeView === 'terminal' ? ( + + ) : ( + previewRef.current?.reload()} /> + )} + + )} + {/* Resize handles */} @@ -612,13 +693,15 @@ const DashboardOutputPreview: React.FC<{ backendResult: any; interactive: boolean; onAppClicked: () => void; -}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked }) => { + onRuntimeLog?: (line: RuntimeLogLine) => void; +}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; const { frontendUrl, isNewMode, isHydrating } = useRuntimePreviewUrl({ workspaceId, enabled: !!workspaceId, + onLog: onRuntimeLog, }); const { url, isBooting } = pickPreviewUrl({ workspaceId, @@ -639,6 +722,8 @@ const DashboardOutputPreview: React.FC<{ }).catch(() => {}); return; } + // Fold console output into the runtime terminal stream (card Terminal view + agent-readable terminal.log). + postAppConsoleLine(workspaceId, level, text); if (level !== 'error' || !text.includes('[openswarm:app-error]')) return; const idx = text.indexOf('[openswarm:app-error]'); const tail = text.slice(idx + '[openswarm:app-error]'.length).trim(); diff --git a/frontend/src/app/pages/Views/AppCodePanel.tsx b/frontend/src/app/pages/Views/AppCodePanel.tsx new file mode 100644 index 00000000..a3b79e95 --- /dev/null +++ b/frontend/src/app/pages/Views/AppCodePanel.tsx @@ -0,0 +1,136 @@ +// Self-contained Code view for the dashboard app card: polls the workspace file +// tree, edits save via the same per-file PUT the full ViewEditor uses. Owns all +// its state so DashboardViewCard mounts it on demand with just a workspaceId. +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { API_BASE } from '@/shared/config'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import CodeEditor from './CodeEditor'; +import { FileTreeItem, buildFileTree, getEditorLanguage, isHiddenWorkspacePath } from './AppFileTree'; + +const POLL_MS = 3000; +const SAVE_DEBOUNCE_MS = 300; + +interface Props { + workspaceId: string; + onFileSaved?: () => void; +} + +const AppCodePanel: React.FC = ({ workspaceId, onFileSaved }) => { + const c = useClaudeTokens(); + const [files, setFiles] = useState>({}); + const [oversizeFiles, setOversizeFiles] = useState>({}); + const [activeFile, setActiveFile] = useState(''); + const lastPollRef = useRef(''); + const saveTimersRef = useRef>>(new Map()); + // Files the user is mid-editing; the poll must not clobber them with a stale disk read racing the debounced PUT. + const dirtyFilesRef = useRef>(new Set()); + + useEffect(() => { + let cancelled = false; + const poll = async () => { + try { + const res = await fetch(`${API_BASE}/outputs/workspace/${workspaceId}`); + if (!res.ok || cancelled) return; + const data = await res.json(); + const fingerprint = JSON.stringify(data.files ?? {}); + if (fingerprint === lastPollRef.current) return; + lastPollRef.current = fingerprint; + setFiles((prev) => { + const next: Record = { ...(data.files ?? {}) }; + for (const dirty of dirtyFilesRef.current) { + if (prev[dirty] != null) next[dirty] = prev[dirty]; + } + return next; + }); + setOversizeFiles(data.truncated ?? {}); + } catch { /* transient poll failure; next tick retries */ } + }; + poll(); + const id = setInterval(poll, POLL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [workspaceId]); + + const filePaths = useMemo( + () => + Array.from(new Set([...Object.keys(files), ...Object.keys(oversizeFiles)])) + .filter((p) => p !== 'meta.json' && p !== 'SKILL.md') + .filter((p) => !isHiddenWorkspacePath(p)) + .sort(), + [files, oversizeFiles], + ); + const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]); + + useEffect(() => { + if (!activeFile || !filePaths.includes(activeFile)) { + setActiveFile(filePaths.find((p) => p.endsWith('.tsx') || p.endsWith('.html')) ?? filePaths[0] ?? ''); + } + }, [filePaths, activeFile]); + + const updateFile = useCallback((path: string, content: string) => { + if (oversizeFiles[path] != null) return; + dirtyFilesRef.current.add(path); + setFiles((prev) => ({ ...prev, [path]: content })); + const existing = saveTimersRef.current.get(path); + if (existing) clearTimeout(existing); + saveTimersRef.current.set(path, setTimeout(() => { + saveTimersRef.current.delete(path); + dirtyFilesRef.current.delete(path); + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/file/${encodeURIComponent(path)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }) + .then(() => onFileSaved?.()) + .catch(() => {}); + }, SAVE_DEBOUNCE_MS)); + }, [workspaceId, oversizeFiles, onFileSaved]); + + useEffect(() => () => { + for (const t of saveTimersRef.current.values()) clearTimeout(t); + }, []); + + return ( + + + {fileTree.map((node) => ( + + ))} + {filePaths.length === 0 && ( + + Loading files… + + )} + + + {activeFile && oversizeFiles[activeFile] != null ? ( + + + This file is {(oversizeFiles[activeFile] / (1024 * 1024)).toFixed(1)} MB, too large to edit here. + + + ) : activeFile && files[activeFile] != null ? ( + updateFile(activeFile, val)} + language={getEditorLanguage(activeFile)} + placeholder={`// ${activeFile}`} + /> + ) : ( + + + Select a file to edit + + + )} + + + ); +}; + +export default AppCodePanel; diff --git a/frontend/src/app/pages/Views/AppFileTree.tsx b/frontend/src/app/pages/Views/AppFileTree.tsx new file mode 100644 index 00000000..84b80447 --- /dev/null +++ b/frontend/src/app/pages/Views/AppFileTree.tsx @@ -0,0 +1,194 @@ +// Workspace file-tree primitives shared by ViewEditor's Code tab and the dashboard card's AppCodePanel. +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Collapse from '@mui/material/Collapse'; +import HtmlIcon from '@mui/icons-material/Code'; +import PythonIcon from '@mui/icons-material/Terminal'; +import SchemaIcon from '@mui/icons-material/DataObject'; +import JsIcon from '@mui/icons-material/Javascript'; +import CssIcon from '@mui/icons-material/Style'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import FolderIcon from '@mui/icons-material/Folder'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +// File-tree noise: filtered by basename anywhere in the path; callers may offer a "show hidden" bypass. +export const HIDDEN_PATH_SEGMENTS = new Set([ + 'node_modules', + '.vite-cache', + '.vite', + '.git', + 'dist', + '.next', + '__pycache__', + '.venv', + '.openswarm', +]); + +export function isHiddenWorkspacePath(p: string): boolean { + for (const seg of p.split('/')) { + if (HIDDEN_PATH_SEGMENTS.has(seg)) return true; + } + return false; +} + +export function getFileIcon(filename: string): React.ReactNode { + const ext = filename.split('.').pop()?.toLowerCase(); + const size = 15; + switch (ext) { + case 'html': case 'htm': return ; + case 'py': return ; + case 'json': return ; + case 'js': case 'jsx': case 'ts': case 'tsx': return ; + case 'css': case 'scss': case 'less': return ; + default: return ; + } +} + +export function getEditorLanguage(filename: string): 'html' | 'python' | 'json' { + const ext = filename.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'py': return 'python'; + case 'json': return 'json'; + default: return 'html'; + } +} + +export interface FileTreeNode { + name: string; + path: string; + isDir: boolean; + children?: FileTreeNode[]; +} + +export function buildFileTree(filePaths: string[]): FileTreeNode[] { + const root: FileTreeNode[] = []; + const sorted = [...filePaths].sort(); + + for (const fp of sorted) { + const parts = fp.split('/'); + let current = root; + let pathSoFar = ''; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; + const isLast = i === parts.length - 1; + + let existing = current.find(n => n.name === part && n.isDir === !isLast); + if (!existing) { + if (isLast) { + existing = { name: part, path: fp, isDir: false }; + } else { + existing = { name: part, path: pathSoFar, isDir: true, children: [] }; + } + current.push(existing); + } + if (!isLast) { + current = existing.children!; + } + } + } + + return root; +} + +interface FileTreeItemProps { + node: FileTreeNode; + depth: number; + activeFile: string; + onSelect: (path: string) => void; + onDelete?: (path: string) => void; + c: ReturnType; +} + +const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); + +export const FileTreeItem: React.FC = ({ node, depth, activeFile, onSelect, onDelete, c }) => { + const [open, setOpen] = useState(true); + + if (node.isDir) { + return ( + <> + setOpen(!open)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + pl: 1.5 + depth * 1, + pr: 1, + py: 0.5, + cursor: 'pointer', + '&:hover': { bgcolor: c.bg.surface }, + }} + > + + + + {node.name} + + + + {node.children?.map((child) => ( + + ))} + + + ); + } + + const isActive = activeFile === node.path; + const canDelete = onDelete && !PROTECTED_FILES.has(node.path); + + return ( + onSelect(node.path)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + pl: 1.5 + depth * 1 + 1.25, + pr: 0.5, + py: 0.5, + cursor: 'pointer', + bgcolor: isActive ? c.bg.elevated : 'transparent', + borderLeft: isActive ? `2px solid ${c.accent.primary}` : '2px solid transparent', + '&:hover': { bgcolor: isActive ? c.bg.elevated : c.bg.surface }, + '&:hover .delete-btn': { opacity: 1 }, + transition: 'background-color 0.1s', + }} + > + + {getFileIcon(node.name)} + + + {node.name} + + {canDelete && ( + { e.stopPropagation(); onDelete(node.path); }} + sx={{ opacity: 0, p: 0.25, color: c.text.ghost, '&:hover': { color: '#ef4444' }, transition: 'opacity 0.15s, color 0.15s' }} + > + + + )} + + ); +}; diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index 02621bea..be1e83f0 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -15,19 +15,10 @@ import MenuItem from '@mui/material/MenuItem'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; -import HtmlIcon from '@mui/icons-material/Code'; -import PythonIcon from '@mui/icons-material/Terminal'; -import SchemaIcon from '@mui/icons-material/DataObject'; -import JsIcon from '@mui/icons-material/Javascript'; -import CssIcon from '@mui/icons-material/Style'; -import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import FolderIcon from '@mui/icons-material/Folder'; import AddIcon from '@mui/icons-material/Add'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import Collapse from '@mui/material/Collapse'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { FileTreeItem, buildFileTree, getEditorLanguage, isHiddenWorkspacePath } from './AppFileTree'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { store } from '@/shared/state/store'; import { createDraftSession, removeDraftSession, fetchSession } from '@/shared/state/agentsSlice'; @@ -44,6 +35,7 @@ import { getDefault } from '@/shared/inputSchemaDefaults'; import CodeEditor from './CodeEditor'; import { ElementSelectionProvider } from '@/app/components/editor/ElementSelectionContext'; import { API_BASE, getAuthToken } from '@/shared/config'; +import { postAppConsoleLine, terminalLineFromStream } from '@/shared/appTerminal'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; const WORKSPACE_API = `${API_BASE}/outputs/workspace`; @@ -96,17 +88,6 @@ const InstallPlaceholder: React.FC = () => { ); }; -// File-tree noise: filtered by basename anywhere in the path; `showHidden` bypasses. -const HIDDEN_PATH_SEGMENTS = new Set([ - 'node_modules', - '.vite-cache', - '.vite', - '.git', - 'dist', - '.next', - '__pycache__', - '.venv', -]); // Poll fast while agent is writing; slow while idle. A one-shot poll fires on active->idle transition to catch the last write. const POLL_INTERVAL_ACTIVE_MS = 2000; const POLL_INTERVAL_IDLE_MS = 15000; @@ -125,164 +106,6 @@ function previewRenderKey(files: Record): string { .join('\n'); } -function getFileIcon(filename: string): React.ReactNode { - const ext = filename.split('.').pop()?.toLowerCase(); - const size = 15; - switch (ext) { - case 'html': case 'htm': return ; - case 'py': return ; - case 'json': return ; - case 'js': case 'jsx': case 'ts': case 'tsx': return ; - case 'css': case 'scss': case 'less': return ; - default: return ; - } -} - -function getEditorLanguage(filename: string): 'html' | 'python' | 'json' { - const ext = filename.split('.').pop()?.toLowerCase(); - switch (ext) { - case 'py': return 'python'; - case 'json': return 'json'; - default: return 'html'; - } -} - -interface FileTreeNode { - name: string; - path: string; - isDir: boolean; - children?: FileTreeNode[]; -} - -function buildFileTree(filePaths: string[]): FileTreeNode[] { - const root: FileTreeNode[] = []; - const sorted = [...filePaths].sort(); - - for (const fp of sorted) { - const parts = fp.split('/'); - let current = root; - let pathSoFar = ''; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; - const isLast = i === parts.length - 1; - - let existing = current.find(n => n.name === part && n.isDir === !isLast); - if (!existing) { - if (isLast) { - existing = { name: part, path: fp, isDir: false }; - } else { - existing = { name: part, path: pathSoFar, isDir: true, children: [] }; - } - current.push(existing); - } - if (!isLast) { - current = existing.children!; - } - } - } - - return root; -} - -interface FileTreeItemProps { - node: FileTreeNode; - depth: number; - activeFile: string; - onSelect: (path: string) => void; - onDelete?: (path: string) => void; - c: ReturnType; -} - -const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']); - -const FileTreeItem: React.FC = ({ node, depth, activeFile, onSelect, onDelete, c }) => { - const [open, setOpen] = useState(true); - - if (node.isDir) { - return ( - <> - setOpen(!open)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.5, - pl: 1.5 + depth * 1, - pr: 1, - py: 0.5, - cursor: 'pointer', - '&:hover': { bgcolor: c.bg.surface }, - }} - > - - - - {node.name} - - - - {node.children?.map((child) => ( - - ))} - - - ); - } - - const isActive = activeFile === node.path; - const canDelete = onDelete && !PROTECTED_FILES.has(node.path); - - return ( - onSelect(node.path)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.75, - pl: 1.5 + depth * 1 + 1.25, - pr: 0.5, - py: 0.5, - cursor: 'pointer', - bgcolor: isActive ? c.bg.elevated : 'transparent', - borderLeft: isActive ? `2px solid ${c.accent.primary}` : '2px solid transparent', - '&:hover': { bgcolor: isActive ? c.bg.elevated : c.bg.surface }, - '&:hover .delete-btn': { opacity: 1 }, - transition: 'background-color 0.1s', - }} - > - - {getFileIcon(node.name)} - - - {node.name} - - {canDelete && ( - { e.stopPropagation(); onDelete(node.path); }} - sx={{ opacity: 0, p: 0.25, color: c.text.ghost, '&:hover': { color: '#ef4444' }, transition: 'opacity 0.15s, color 0.15s' }} - > - - - )} - - ); -}; - interface Props { output: Output | null; onClose: () => void; @@ -868,6 +691,12 @@ const ViewEditor: React.FC = ({ output }) => { }, []); const handleWebviewConsole = useCallback((level: string, text: string) => { + // Workspace apps: beacon the line into the backend runtime stream (agent-readable terminal.log); it echoes back over the logs WS, so no local append or we'd double-print. Legacy flat apps have no runtime, so append locally. + const wsId = workspaceIdRef.current; + if (wsId) { + postAppConsoleLine(wsId, level, text); + return; + } appendTerminalLine('frontend', level, text); }, [appendTerminalLine]); @@ -936,11 +765,8 @@ const ViewEditor: React.FC = ({ output }) => { } else if (msg.event === 'runtime:log') { const stream = msg.data?.stream || 'stdout'; const text = msg.data?.text || ''; - if (stream === 'runtime') { - appendTerminalLine('runtime', 'info', text); - } else { - appendTerminalLine('backend', stream, text); - } + const fields = terminalLineFromStream(stream, text); + appendTerminalLine(fields.source, fields.level, fields.text); } } catch (_) {} }; @@ -1008,11 +834,7 @@ const ViewEditor: React.FC = ({ output }) => { // VSCode-style files.exclude predicate; single source of truth for list/tree/open-file routing. const isHiddenPath = useCallback((p: string): boolean => { if (showHidden) return false; - const segments = p.split('/'); - for (const seg of segments) { - if (HIDDEN_PATH_SEGMENTS.has(seg)) return true; - } - return false; + return isHiddenWorkspacePath(p); }, [showHidden]); const filePaths = useMemo( @@ -1299,11 +1121,12 @@ const ViewEditor: React.FC = ({ output }) => { - {activeTab === TAB_PREVIEW && ( - + {(activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL) && ( + previewRef.current?.reload()} + // In Terminal view a soft webview reload is invisible, so the button always hard-reloads there. + onClick={() => { if (activeTab === TAB_TERMINAL) { void handleHardReload(); } else { previewRef.current?.reload(); } }} onContextMenu={(e) => { e.preventDefault(); setReloadMenuAnchor(e.currentTarget as HTMLElement); diff --git a/frontend/src/shared/appTerminal.ts b/frontend/src/shared/appTerminal.ts new file mode 100644 index 00000000..d01d3a72 --- /dev/null +++ b/frontend/src/shared/appTerminal.ts @@ -0,0 +1,61 @@ +// App terminal plumbing shared by the ViewEditor and dashboard-card Terminal panes: +// a batched beacon that folds webview console lines into the backend runtime stream +// (ring buffer -> WS subscribers -> agent-readable .openswarm/terminal.log), and the +// stream->TerminalLine mapping for lines arriving back over the runtime logs WS. +import { API_BASE, getAuthToken } from '@/shared/config'; + +export interface AppTerminalLineFields { + source: 'frontend' | 'backend' | 'runtime'; + level: string; + text: string; +} + +// Batched so a chatty console (tick loops, HMR spam) costs one request/second, not one per line. +const FLUSH_MS = 1000; +const MAX_LINES_PER_FLUSH = 50; + +interface PendingConsoleLine { level: string; text: string } + +const pendingByWorkspace = new Map(); +const flushTimers = new Map(); + +function flushConsoleLines(workspaceId: string): void { + flushTimers.delete(workspaceId); + const queue = pendingByWorkspace.get(workspaceId); + if (!queue || queue.length === 0) return; + const batch = queue.splice(0, MAX_LINES_PER_FLUSH); + if (queue.length > 0) { + batch.push({ level: 'warn', text: `[console] dropped ${queue.length} lines (rate cap)` }); + queue.length = 0; + } + const tok = getAuthToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (tok) headers.Authorization = `Bearer ${tok}`; + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/console-log`, { + method: 'POST', + headers, + body: JSON.stringify({ lines: batch }), + }).catch(() => {}); +} + +export function postAppConsoleLine(workspaceId: string, level: string, text: string): void { + if (!workspaceId || !text) return; + let queue = pendingByWorkspace.get(workspaceId); + if (!queue) { + queue = []; + pendingByWorkspace.set(workspaceId, queue); + } + queue.push({ level, text }); + if (!flushTimers.has(workspaceId)) { + flushTimers.set(workspaceId, window.setTimeout(() => flushConsoleLines(workspaceId), FLUSH_MS)); + } +} + +export function terminalLineFromStream(stream: string, text: string): AppTerminalLineFields { + if (stream === 'runtime') return { source: 'runtime', level: 'info', text }; + if (stream.startsWith('frontend')) { + const level = stream === 'frontend-warn' ? 'warn' : stream === 'frontend-error' ? 'error' : 'log'; + return { source: 'frontend', level, text }; + } + return { source: 'backend', level: stream, text }; +} diff --git a/scripts/fetch-webapp-template.sh b/scripts/fetch-webapp-template.sh index c0699656..ff588dcf 100755 --- a/scripts/fetch-webapp-template.sh +++ b/scripts/fetch-webapp-template.sh @@ -99,6 +99,7 @@ __pycache__/ *.pyc dist/ build/ +.openswarm/ EOF # Patch 3: backend_init.sh — copied verbatim into every new workspace.