[eric] apps: card Preview/Code/Terminal switcher, terminal hard-reload, agent-readable terminal.log

This commit is contained in:
ciregenz
2026-07-02 00:35:13 -07:00
parent 77ff2b79e9
commit f19284a456
11 changed files with 585 additions and 203 deletions
+10 -2
View File
@@ -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:
+18
View File
@@ -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
+52 -1
View File
@@ -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())
@@ -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
@@ -6,3 +6,4 @@ __pycache__/
*.pyc
dist/
build/
.openswarm/
@@ -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<Props> = ({
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | null>(null);
// Preview/Code/Terminal switcher; only new-mode (workspace-backed) apps have code + terminal to show.
const [activeView, setActiveView] = useState<AppCardView>('preview');
const hasWorkspace = !!output.workspace_id;
const [terminalLines, setTerminalLines] = useState<TerminalLine[]>([]);
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<Props> = ({
void removeViewCardCleanly(output.id, dispatch);
};
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation();
previewRef.current?.reload();
};
const [reloadMenuRect, setReloadMenuRect] = useState<DOMRect | null>(null);
const handleHardReload = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -332,6 +352,16 @@ const DashboardViewCard: React.FC<Props> = ({
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<Props> = ({
{output.name}
</Typography>
<Tooltip title="Reload preview; right-click for Hard Reload" placement="top">
{hasWorkspace && (
<Box
onPointerDown={(e) => 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 }) => (
<Tooltip key={view} title={label} placement="top">
<IconButton
size="small"
onClick={(e) => { 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` },
}}
>
<Icon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
))}
</Box>
)}
<Tooltip
title={activeView === 'terminal' ? 'Hard reload (restart runtime + reload app)' : 'Reload preview; right-click for Hard Reload'}
placement="top"
>
<IconButton
size="small"
onClick={handleRefresh}
@@ -488,8 +558,19 @@ const DashboardViewCard: React.FC<Props> = ({
backendResult={backendResult}
interactive={interactive}
onAppClicked={() => dispatch(setActiveViewCardId(output.id))}
onRuntimeLog={handleRuntimeLog}
/>
<BuildingOverlay show={showBuildingOverlay} />
{/* 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' && (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 13, bgcolor: c.bg.surface }}>
{activeView === 'terminal' ? (
<TerminalPanel lines={terminalLines} />
) : (
<AppCodePanel workspaceId={output.workspace_id} onFileSaved={() => previewRef.current?.reload()} />
)}
</Box>
)}
<BuildingOverlay show={showBuildingOverlay && activeView === 'preview'} />
</Box>
{/* 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();
@@ -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<Props> = ({ workspaceId, onFileSaved }) => {
const c = useClaudeTokens();
const [files, setFiles] = useState<Record<string, string>>({});
const [oversizeFiles, setOversizeFiles] = useState<Record<string, number>>({});
const [activeFile, setActiveFile] = useState('');
const lastPollRef = useRef('');
const saveTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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<Set<string>>(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<string, string> = { ...(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 (
<Box sx={{ display: 'flex', height: '100%', bgcolor: c.bg.surface }}>
<Box sx={{ width: 168, flexShrink: 0, bgcolor: c.bg.secondary, overflow: 'auto', py: 0.5, borderRight: `1px solid ${c.border.subtle}` }}>
{fileTree.map((node) => (
<FileTreeItem key={node.path} node={node} depth={0} activeFile={activeFile} onSelect={setActiveFile} c={c} />
))}
{filePaths.length === 0 && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.ghost, px: 1.5, py: 1 }}>
Loading files
</Typography>
)}
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
{activeFile && oversizeFiles[activeFile] != null ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', px: 3 }}>
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', textAlign: 'center', maxWidth: 320, lineHeight: 1.5 }}>
This file is {(oversizeFiles[activeFile] / (1024 * 1024)).toFixed(1)} MB, too large to edit here.
</Typography>
</Box>
) : activeFile && files[activeFile] != null ? (
<CodeEditor
key={activeFile}
value={files[activeFile]}
onChange={(val) => updateFile(activeFile, val)}
language={getEditorLanguage(activeFile)}
placeholder={`// ${activeFile}`}
/>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>
Select a file to edit
</Typography>
</Box>
)}
</Box>
</Box>
);
};
export default AppCodePanel;
@@ -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<string>([
'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 <HtmlIcon sx={{ fontSize: size }} />;
case 'py': return <PythonIcon sx={{ fontSize: size }} />;
case 'json': return <SchemaIcon sx={{ fontSize: size }} />;
case 'js': case 'jsx': case 'ts': case 'tsx': return <JsIcon sx={{ fontSize: size }} />;
case 'css': case 'scss': case 'less': return <CssIcon sx={{ fontSize: size }} />;
default: return <InsertDriveFileIcon sx={{ fontSize: size }} />;
}
}
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<typeof useClaudeTokens>;
}
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
const [open, setOpen] = useState(true);
if (node.isDir) {
return (
<>
<Box
onClick={() => 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 },
}}
>
<ExpandMoreIcon sx={{ fontSize: 12, color: c.text.ghost, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: '0.15s' }} />
<FolderIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary, fontFamily: c.font.mono }}>
{node.name}
</Typography>
</Box>
<Collapse in={open}>
{node.children?.map((child) => (
<FileTreeItem key={child.path} node={child} depth={depth + 1} activeFile={activeFile} onSelect={onSelect} onDelete={onDelete} c={c} />
))}
</Collapse>
</>
);
}
const isActive = activeFile === node.path;
const canDelete = onDelete && !PROTECTED_FILES.has(node.path);
return (
<Box
onClick={() => 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',
}}
>
<Box sx={{ color: isActive ? c.accent.primary : c.text.muted, display: 'flex', flexShrink: 0 }}>
{getFileIcon(node.name)}
</Box>
<Typography
sx={{
fontSize: '0.74rem',
fontFamily: c.font.mono,
color: isActive ? c.text.primary : c.text.secondary,
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{node.name}
</Typography>
{canDelete && (
<IconButton
className="delete-btn"
size="small"
onClick={(e) => { 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' }}
>
<DeleteOutlineIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
);
};
+15 -192
View File
@@ -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<string>([
'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, string>): 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 <HtmlIcon sx={{ fontSize: size }} />;
case 'py': return <PythonIcon sx={{ fontSize: size }} />;
case 'json': return <SchemaIcon sx={{ fontSize: size }} />;
case 'js': case 'jsx': case 'ts': case 'tsx': return <JsIcon sx={{ fontSize: size }} />;
case 'css': case 'scss': case 'less': return <CssIcon sx={{ fontSize: size }} />;
default: return <InsertDriveFileIcon sx={{ fontSize: size }} />;
}
}
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<typeof useClaudeTokens>;
}
const PROTECTED_FILES = new Set(['index.html', 'schema.json', 'meta.json', 'SKILL.md']);
const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth, activeFile, onSelect, onDelete, c }) => {
const [open, setOpen] = useState(true);
if (node.isDir) {
return (
<>
<Box
onClick={() => 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 },
}}
>
<ExpandMoreIcon sx={{ fontSize: 12, color: c.text.ghost, transform: open ? 'rotate(0deg)' : 'rotate(-90deg)', transition: '0.15s' }} />
<FolderIcon sx={{ fontSize: 14, color: c.text.muted }} />
<Typography sx={{ fontSize: '0.74rem', color: c.text.secondary, fontFamily: c.font.mono }}>
{node.name}
</Typography>
</Box>
<Collapse in={open}>
{node.children?.map((child) => (
<FileTreeItem key={child.path} node={child} depth={depth + 1} activeFile={activeFile} onSelect={onSelect} onDelete={onDelete} c={c} />
))}
</Collapse>
</>
);
}
const isActive = activeFile === node.path;
const canDelete = onDelete && !PROTECTED_FILES.has(node.path);
return (
<Box
onClick={() => 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',
}}
>
<Box sx={{ color: isActive ? c.accent.primary : c.text.muted, display: 'flex', flexShrink: 0 }}>
{getFileIcon(node.name)}
</Box>
<Typography
sx={{
fontSize: '0.74rem',
fontFamily: c.font.mono,
color: isActive ? c.text.primary : c.text.secondary,
fontWeight: isActive ? 500 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{node.name}
</Typography>
{canDelete && (
<IconButton
className="delete-btn"
size="small"
onClick={(e) => { 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' }}
>
<DeleteOutlineIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
);
};
interface Props {
output: Output | null;
onClose: () => void;
@@ -868,6 +691,12 @@ const ViewEditor: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ output }) => {
<Tab disableRipple label="Terminal" value={TAB_TERMINAL} />
<Tab disableRipple label="History" value={TAB_HISTORY} />
</Tabs>
{activeTab === TAB_PREVIEW && (
<Tooltip title="Reload preview; right-click for Hard Reload">
{(activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL) && (
<Tooltip title={activeTab === TAB_TERMINAL ? 'Hard reload (restart runtime + reload preview)' : 'Reload preview; right-click for Hard Reload'}>
<IconButton
size="small"
onClick={() => 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);
+61
View File
@@ -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<string, PendingConsoleLine[]>();
const flushTimers = new Map<string, number>();
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<string, string> = { '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 };
}
+1
View File
@@ -99,6 +99,7 @@ __pycache__/
*.pyc
dist/
build/
.openswarm/
EOF
# Patch 3: backend_init.sh — copied verbatim into every new workspace.