+
diff --git a/src/hooks/useExecutionSync.ts b/src/hooks/useExecutionSync.ts
new file mode 100644
index 0000000..cd1d376
--- /dev/null
+++ b/src/hooks/useExecutionSync.ts
@@ -0,0 +1,109 @@
+import { useCallback, useEffect } from 'react';
+import { useExecutionStore } from '../stores/executionStore';
+import { useEditorStore } from '../stores/editorStore';
+import { runCode, stopExecution as stopWorker, isExecutable } from '../services/code-executor';
+import type { DataChannelMessage } from '../services/connection-manager';
+
+interface UseExecutionSyncOptions {
+ sendMessage?: (data: DataChannelMessage) => boolean;
+}
+
+interface UseExecutionSyncReturn {
+ handleMessage: (message: DataChannelMessage) => void;
+}
+
+/**
+ * useExecutionSync — runs code locally and syncs results over the data channel.
+ *
+ * - executeCode() reads the current editor code/language, runs it in a Worker,
+ * broadcasts execution-start and execution-result to peers.
+ * - handleMessage() receives execution events from peers and updates the store.
+ */
+export function useExecutionSync({
+ sendMessage,
+}: UseExecutionSyncOptions = {}): UseExecutionSyncReturn {
+ const startExecution = useExecutionStore((s) => s.startExecution);
+ const setResult = useExecutionStore((s) => s.setResult);
+ const stopExecution = useExecutionStore((s) => s.stopExecution);
+ const setCallbacks = useExecutionStore((s) => s.setCallbacks);
+
+ const executeCode = useCallback(async () => {
+ const { code, language } = useEditorStore.getState();
+ if (!isExecutable(language)) return;
+
+ startExecution();
+
+ if (sendMessage) {
+ sendMessage({
+ type: 'execution-start',
+ language,
+ timestamp: Date.now(),
+ });
+ }
+
+ try {
+ const result = await runCode(code, language);
+ setResult(result);
+
+ if (sendMessage) {
+ sendMessage({
+ type: 'execution-result',
+ stdout: result.stdout,
+ stderr: result.stderr,
+ exitCode: result.exitCode,
+ duration: result.duration,
+ });
+ }
+ } catch {
+ const errorResult = {
+ stdout: '',
+ stderr: 'Execution failed',
+ exitCode: 1,
+ duration: 0,
+ };
+ setResult(errorResult);
+
+ if (sendMessage) {
+ sendMessage({
+ type: 'execution-result',
+ ...errorResult,
+ });
+ }
+ }
+ }, [sendMessage, startExecution, setResult]);
+
+ const cancelExecution = useCallback(() => {
+ stopWorker();
+ stopExecution();
+ }, [stopExecution]);
+
+ // Register callbacks on the store so components can trigger execution
+ useEffect(() => {
+ setCallbacks(executeCode, cancelExecution);
+ }, [executeCode, cancelExecution, setCallbacks]);
+
+ const handleMessage = useCallback(
+ (message: DataChannelMessage) => {
+ switch (message.type) {
+ case 'execution-start':
+ startExecution();
+ break;
+
+ case 'execution-result':
+ setResult({
+ stdout: message.stdout,
+ stderr: message.stderr,
+ exitCode: message.exitCode,
+ duration: message.duration,
+ });
+ break;
+
+ default:
+ break;
+ }
+ },
+ [startExecution, setResult],
+ );
+
+ return { handleMessage };
+}
diff --git a/src/services/code-executor.ts b/src/services/code-executor.ts
new file mode 100644
index 0000000..6ab7267
--- /dev/null
+++ b/src/services/code-executor.ts
@@ -0,0 +1,67 @@
+/**
+ * code-executor.ts — Framework-agnostic service for running JS/TS code
+ * in a sandboxed Web Worker.
+ */
+
+import type { ExecutionResult } from '../stores/executionStore';
+
+const EXECUTABLE_LANGUAGES = new Set(['javascript', 'typescript']);
+
+let worker: Worker | null = null;
+let currentReject: ((reason: string) => void) | null = null;
+
+export function isExecutable(language: string): boolean {
+ return EXECUTABLE_LANGUAGES.has(language);
+}
+
+export function runCode(
+ code: string,
+ language: string,
+ timeout = 10_000,
+): Promise {
+ // Terminate any in-progress run
+ terminateWorker();
+
+ return new Promise((resolve, reject) => {
+ currentReject = reject;
+
+ worker = new Worker(
+ new URL('./code-executor.worker.ts', import.meta.url),
+ { type: 'module' },
+ );
+
+ worker.onmessage = (event: MessageEvent) => {
+ if (event.data.type === 'result') {
+ resolve(event.data as ExecutionResult);
+ cleanupWorker();
+ }
+ };
+
+ worker.onerror = (event: ErrorEvent) => {
+ reject(event.message || 'Worker error');
+ cleanupWorker();
+ };
+
+ worker.postMessage({ type: 'run', code, language, timeout });
+ });
+}
+
+export function stopExecution(): void {
+ if (currentReject) {
+ currentReject('Execution stopped by user');
+ }
+ terminateWorker();
+}
+
+function terminateWorker(): void {
+ if (worker) {
+ worker.terminate();
+ worker = null;
+ }
+ currentReject = null;
+}
+
+function cleanupWorker(): void {
+ worker = null;
+ currentReject = null;
+}
diff --git a/src/services/code-executor.worker.ts b/src/services/code-executor.worker.ts
new file mode 100644
index 0000000..fbfc693
--- /dev/null
+++ b/src/services/code-executor.worker.ts
@@ -0,0 +1,144 @@
+/**
+ * code-executor.worker.ts — Sandboxed Web Worker for JS/TS code execution.
+ *
+ * Receives { type: 'run', code, language } messages.
+ * Captures console output, enforces a timeout, and posts back results.
+ */
+
+import ts from 'typescript';
+
+interface RunMessage {
+ type: 'run';
+ code: string;
+ language: string;
+ timeout?: number;
+}
+
+interface OutputLine {
+ stream: 'stdout' | 'stderr';
+ text: string;
+}
+
+const DEFAULT_TIMEOUT = 10_000; // 10 seconds
+const MAX_OUTPUT_SIZE = 100_000; // 100KB per stream
+
+self.onmessage = (event: MessageEvent) => {
+ const { code, language, timeout = DEFAULT_TIMEOUT } = event.data;
+ if (event.data.type !== 'run') return;
+
+ const output: OutputLine[] = [];
+ let totalSize = 0;
+ let truncated = false;
+
+ function addOutput(stream: 'stdout' | 'stderr', args: unknown[]) {
+ if (truncated) return;
+ const text = args.map(stringifyArg).join(' ');
+ totalSize += text.length;
+ if (totalSize > MAX_OUTPUT_SIZE) {
+ output.push({ stream: 'stderr', text: '\n[Output truncated — exceeded 100KB limit]' });
+ truncated = true;
+ return;
+ }
+ output.push({ stream, text });
+ }
+
+ // Override console methods to capture output
+ const fakeConsole = {
+ log: (...args: unknown[]) => addOutput('stdout', args),
+ info: (...args: unknown[]) => addOutput('stdout', args),
+ warn: (...args: unknown[]) => addOutput('stderr', args),
+ error: (...args: unknown[]) => addOutput('stderr', args),
+ debug: (...args: unknown[]) => addOutput('stdout', args),
+ dir: (...args: unknown[]) => addOutput('stdout', args),
+ table: (...args: unknown[]) => addOutput('stdout', args),
+ clear: () => { output.length = 0; totalSize = 0; truncated = false; },
+ };
+
+ const start = performance.now();
+
+ // Timeout guard
+ const timeoutId = setTimeout(() => {
+ const duration = Math.round(performance.now() - start);
+ self.postMessage({
+ type: 'result',
+ stdout: collectStream(output, 'stdout'),
+ stderr: collectStream(output, 'stderr') + `\n[Execution timed out after ${timeout / 1000}s]`,
+ exitCode: 1,
+ duration,
+ });
+ self.close();
+ }, timeout);
+
+ try {
+ let jsCode = code;
+
+ if (language === 'typescript') {
+ const result = ts.transpileModule(code, {
+ compilerOptions: {
+ target: ts.ScriptTarget.ES2022,
+ module: ts.ModuleKind.ESNext,
+ strict: false,
+ esModuleInterop: true,
+ skipLibCheck: true,
+ },
+ });
+ jsCode = result.outputText;
+ }
+
+ // Execute in a closure that provides our fake console
+ const wrappedCode = `
+ "use strict";
+ return (function(console) {
+ ${jsCode}
+ });
+ `;
+ const factory = new Function(wrappedCode);
+ const executor = factory();
+ executor(fakeConsole);
+
+ clearTimeout(timeoutId);
+ const duration = Math.round(performance.now() - start);
+
+ self.postMessage({
+ type: 'result',
+ stdout: collectStream(output, 'stdout'),
+ stderr: collectStream(output, 'stderr'),
+ exitCode: 0,
+ duration,
+ });
+ } catch (error: unknown) {
+ clearTimeout(timeoutId);
+ const duration = Math.round(performance.now() - start);
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ const errorStack = error instanceof Error && error.stack ? error.stack : '';
+
+ self.postMessage({
+ type: 'result',
+ stdout: collectStream(output, 'stdout'),
+ stderr: collectStream(output, 'stderr') +
+ (output.some(l => l.stream === 'stderr') ? '\n' : '') +
+ (errorStack || errorMessage),
+ exitCode: 1,
+ duration,
+ });
+ }
+};
+
+function stringifyArg(arg: unknown): string {
+ if (arg === null) return 'null';
+ if (arg === undefined) return 'undefined';
+ if (typeof arg === 'string') return arg;
+ if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]`;
+ try {
+ return JSON.stringify(arg, null, 2);
+ } catch {
+ return String(arg);
+ }
+}
+
+function collectStream(output: OutputLine[], stream: 'stdout' | 'stderr'): string {
+ return output
+ .filter((l) => l.stream === stream)
+ .map((l) => l.text)
+ .join('\n');
+}
diff --git a/src/services/connection-manager.ts b/src/services/connection-manager.ts
index b4a9cf9..f87ecb5 100644
--- a/src/services/connection-manager.ts
+++ b/src/services/connection-manager.ts
@@ -29,7 +29,9 @@ export type DataChannelMessage =
| { type: 'canvas-clear' }
| { type: 'canvas-sync'; strokes: Stroke[]; zoom?: number; panOffset?: Point }
| { type: 'message'; id: string; text: string; sender: string; timestamp: number }
- | { type: 'message-ack'; messageId: string };
+ | { type: 'message-ack'; messageId: string }
+ | { type: 'execution-start'; language: string; timestamp: number }
+ | { type: 'execution-result'; stdout: string; stderr: string; exitCode: number; duration: number };
export interface ConnectionManagerOptions {
stunServers?: RTCIceServer[];
diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts
new file mode 100644
index 0000000..a3cb463
--- /dev/null
+++ b/src/stores/executionStore.ts
@@ -0,0 +1,58 @@
+import { create } from 'zustand';
+
+export interface ExecutionResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+ duration: number;
+}
+
+interface ExecutionState {
+ isRunning: boolean;
+ output: ExecutionResult | null;
+ panelExpanded: boolean;
+ /** Set by useExecutionSync — triggers code execution with sync. */
+ runCode: (() => void) | null;
+ /** Set by useExecutionSync — cancels in-progress execution. */
+ cancelCode: (() => void) | null;
+}
+
+interface ExecutionActions {
+ startExecution: () => void;
+ setResult: (result: ExecutionResult) => void;
+ stopExecution: () => void;
+ togglePanel: () => void;
+ setCallbacks: (run: () => void, cancel: () => void) => void;
+ reset: () => void;
+}
+
+export type ExecutionStore = ExecutionState & ExecutionActions;
+
+const initialState: ExecutionState = {
+ isRunning: false,
+ output: null,
+ panelExpanded: false,
+ runCode: null,
+ cancelCode: null,
+};
+
+export const useExecutionStore = create((set) => ({
+ ...initialState,
+
+ startExecution: () =>
+ set({ isRunning: true, output: null, panelExpanded: true }),
+
+ setResult: (result) =>
+ set({ isRunning: false, output: result }),
+
+ stopExecution: () =>
+ set({ isRunning: false }),
+
+ togglePanel: () =>
+ set((state) => ({ panelExpanded: !state.panelExpanded })),
+
+ setCallbacks: (run, cancel) =>
+ set({ runCode: run, cancelCode: cancel }),
+
+ reset: () => set(initialState),
+}));
diff --git a/src/styles.css b/src/styles.css
index 27c0ae5..21cbb14 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -912,7 +912,8 @@ button:disabled {
}
#languageSelector select {
- padding: 6px 10px;
+ height: 36px;
+ padding: 0 10px;
border: 1px solid var(--border-tertiary);
border-radius: 4px;
background-color: var(--bg-secondary);
@@ -942,6 +943,163 @@ button:disabled {
display: none;
}
+/* Run / Stop button in tab bar */
+.run-btn {
+ color: #22c55e !important;
+ border-color: #22c55e !important;
+ margin-left: 4px;
+ margin-bottom: 4px;
+}
+
+.run-btn:hover {
+ background-color: #22c55e !important;
+ color: white !important;
+}
+
+.stop-btn {
+ color: #ef4444 !important;
+ border-color: #ef4444 !important;
+ margin-left: 4px;
+ margin-bottom: 4px;
+}
+
+.stop-btn:hover {
+ background-color: #ef4444 !important;
+ color: white !important;
+}
+
+/* Output Panel */
+.output-panel {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ border-top: 1px solid var(--border-primary);
+ background-color: var(--code-bg);
+ border-radius: 0 0 4px 4px;
+ overflow: hidden;
+ transition: max-height 0.2s ease;
+}
+
+.output-panel.expanded {
+ max-height: 40vh;
+ min-height: 80px;
+}
+
+.output-panel.collapsed {
+ max-height: 32px;
+}
+
+.output-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ background-color: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-tertiary);
+ cursor: pointer;
+ user-select: none;
+ flex-shrink: 0;
+ min-height: 32px;
+}
+
+.output-header:hover {
+ background-color: var(--bg-hover);
+}
+
+.output-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.output-title {
+ font-weight: 600;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.output-duration {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ background-color: var(--bg-hover);
+ padding: 1px 6px;
+ border-radius: 3px;
+}
+
+.output-running-label {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ font-style: italic;
+}
+
+.output-status-icon {
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.output-status-icon.success {
+ color: #22c55e;
+}
+
+.output-status-icon.error {
+ color: #ef4444;
+}
+
+.output-spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid var(--border-tertiary);
+ border-top-color: var(--accent-primary);
+ border-radius: 50%;
+ animation: output-spin 0.6s linear infinite;
+}
+
+@keyframes output-spin {
+ to { transform: rotate(360deg); }
+}
+
+.output-toggle {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ padding: 2px;
+ display: flex;
+ align-items: center;
+}
+
+.output-body {
+ flex: 1;
+ overflow: auto;
+ padding: 10px 12px;
+ margin: 0;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-primary);
+ min-height: 0;
+}
+
+.output-stdout {
+ color: var(--text-primary);
+}
+
+.output-stderr {
+ color: #ef4444;
+}
+
+.output-placeholder {
+ color: var(--text-tertiary);
+ font-style: italic;
+}
+
#codeEditorWrapper {
flex: 1;
position: relative;
@@ -2058,6 +2216,15 @@ button:disabled {
font-size: 14px;
}
+ .output-panel.expanded {
+ max-height: 30vh;
+ }
+
+ .output-body {
+ font-size: 12px;
+ padding: 8px;
+ }
+
#codeInput,
#codeHighlight {
font-size: 13px;
diff --git a/src/version.ts b/src/version.ts
index 72f53fa..3068eab 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = '2.3';
+export const APP_VERSION = '2.4';