From 5cf8d3ea45a1d72a649030190bc7984a56e9c53b Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sun, 10 May 2026 13:34:42 -0700 Subject: [PATCH 1/3] Refactor AppConsole to a shared data model Signed-off-by: Jeremy lewi --- .../components/AppConsole/AppConsole.test.tsx | 2 + app/src/components/AppConsole/AppConsole.tsx | 353 ++++---------- .../WebMcpToolRegistrationHost.test.tsx | 74 ++- .../WebMcp/WebMcpToolRegistrationHost.tsx | 43 +- app/src/lib/appConsole/AppConsoleData.test.ts | 103 ++++ app/src/lib/appConsole/AppConsoleData.ts | 444 ++++++++++++++++++ .../lib/appConsole/appConsoleController.ts | 15 + .../lib/appConsole/useAppConsoleSnapshot.ts | 18 + app/src/lib/runtime/codeModeExecutor.test.ts | 25 + app/src/lib/runtime/codeModeExecutor.ts | 32 +- .../design/20260510_app_console_model_view.md | 406 ++++++++++++++++ docs-dev/index.md | 1 + 12 files changed, 1229 insertions(+), 287 deletions(-) create mode 100644 app/src/lib/appConsole/AppConsoleData.test.ts create mode 100644 app/src/lib/appConsole/AppConsoleData.ts create mode 100644 app/src/lib/appConsole/appConsoleController.ts create mode 100644 app/src/lib/appConsole/useAppConsoleSnapshot.ts create mode 100644 docs-dev/design/20260510_app_console_model_view.md diff --git a/app/src/components/AppConsole/AppConsole.test.tsx b/app/src/components/AppConsole/AppConsole.test.tsx index c6b7654..c6138d2 100644 --- a/app/src/components/AppConsole/AppConsole.test.tsx +++ b/app/src/components/AppConsole/AppConsole.test.tsx @@ -218,6 +218,7 @@ vi.mock("../Actions/Editor", () => ({ })); import AppConsole from "./AppConsole"; +import { __resetAppConsoleDataForTests } from "../../lib/appConsole/appConsoleController"; function getCurrentCell(): HTMLElement { const current = document.querySelector( @@ -245,6 +246,7 @@ async function flushPersistence() { describe("AppConsole", () => { beforeEach(() => { + __resetAppConsoleDataForTests(); storedSessionId = "session-1"; storedCells = []; touchedSessions.length = 0; diff --git a/app/src/components/AppConsole/AppConsole.tsx b/app/src/components/AppConsole/AppConsole.tsx index f10eff7..58ef61f 100644 --- a/app/src/components/AppConsole/AppConsole.tsx +++ b/app/src/components/AppConsole/AppConsole.tsx @@ -9,6 +9,8 @@ import { useNotebookStore } from "../../contexts/NotebookStoreContext"; import { useRunners } from "../../contexts/RunnersContext"; import { useWorkspace } from "../../contexts/WorkspaceContext"; import { appState } from "../../lib/runtime/AppState"; +import { getAppConsoleData } from "../../lib/appConsole/appConsoleController"; +import { useAppConsoleSnapshot } from "../../lib/appConsole/useAppConsoleSnapshot"; import { createAppJsGlobals } from "../../lib/runtime/appJsGlobals"; import { JSKernel } from "../../lib/runtime/jsKernel"; import { @@ -23,18 +25,10 @@ import { import { parser_pb } from "../../runme/client"; import { ActionOutputItems } from "../Actions/Actions"; import Editor from "../Actions/Editor"; -import { - coerceRestoredCells, - createDraftCell, - createResultOutput, - createStdTextOutputs, - type ConsoleCell, -} from "./model"; -import { appConsoleStorage } from "./storage"; +import { type ConsoleCell, getHistorySources } from "./model"; const STORAGE_KEY = "runme.appConsoleCollapsed"; const LEGACY_STORAGE_KEY = "aisre.appConsoleCollapsed"; -const PERSIST_DEBOUNCE_MS = 150; const textDecoder = new TextDecoder(); type OutputKind = "stdout" | "stderr" | "result"; @@ -137,6 +131,8 @@ function OutputGroups({ outputs }: { outputs: parser_pb.CellOutput[] }) { } export default function AppConsole({ showHeader = true }: { showHeader?: boolean }) { + const appConsoleData = getAppConsoleData(); + const { cells, hydrated, loadError } = useAppConsoleSnapshot(); const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") { return false; @@ -151,10 +147,6 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean return false; } }); - const [cells, setCells] = useState(() => [createDraftCell(1)]); - const [sessionId, setSessionId] = useState(null); - const [hydrated, setHydrated] = useState(false); - const [loadError, setLoadError] = useState(null); const draftEditorRef = useRef(null); const bodyRef = useRef(null); @@ -163,7 +155,6 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean draftBuffer: "", }); const pendingFocusCellIdRef = useRef(null); - const persistenceEnabledRef = useRef(true); useEffect(() => { try { @@ -257,113 +248,6 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean const currentCell = cells[cells.length - 1] ?? null; - const persistCells = useCallback( - async (rows: ConsoleCell[]) => { - if (!sessionId || !persistenceEnabledRef.current) { - return; - } - const updatedAt = new Date().toISOString(); - await appConsoleStorage.saveCells( - rows.map((row) => ({ - ...row, - sessionId, - updatedAt, - })), - ); - await appConsoleStorage.touchSession(sessionId, updatedAt); - }, - [sessionId], - ); - - useEffect(() => { - let cancelled = false; - - void (async () => { - const now = new Date().toISOString(); - try { - const restored = await appConsoleStorage.loadLatestSession(); - if (cancelled) { - return; - } - - if (!restored) { - const session = await appConsoleStorage.createSession(now); - if (cancelled) { - return; - } - const nextCells = [createDraftCell(1)]; - await appConsoleStorage.saveCells( - nextCells.map((cell) => ({ - ...cell, - sessionId: session.id, - updatedAt: now, - })), - ); - setSessionId(session.id); - setCells(nextCells); - pendingFocusCellIdRef.current = nextCells[0].id; - setHydrated(true); - return; - } - - const recovered = coerceRestoredCells(restored.cells, now); - if (recovered.mutated) { - await appConsoleStorage.saveCells( - recovered.cells.map((cell) => ({ - ...cell, - sessionId: restored.session.id, - updatedAt: now, - })), - ); - await appConsoleStorage.touchSession(restored.session.id, now); - } - - if (cancelled) { - return; - } - - setSessionId(restored.session.id); - setCells(recovered.cells); - pendingFocusCellIdRef.current = - recovered.cells[recovered.cells.length - 1]?.id ?? null; - setHydrated(true); - } catch (error) { - console.error("Failed to restore App Console session", error); - persistenceEnabledRef.current = false; - const fallbackSessionId = - globalThis.crypto?.randomUUID?.() ?? `app-console-fallback-${Date.now()}`; - const fallbackCells = [createDraftCell(1)]; - if (!cancelled) { - setLoadError("Console history is unavailable for this session."); - setSessionId(fallbackSessionId); - setCells(fallbackCells); - pendingFocusCellIdRef.current = fallbackCells[0].id; - setHydrated(true); - } - } - })(); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (!hydrated || !sessionId || !persistenceEnabledRef.current) { - return; - } - - const timeout = window.setTimeout(() => { - void persistCells(cells).catch((error) => { - console.error("Failed to persist App Console state", error); - }); - }, PERSIST_DEBOUNCE_MS); - - return () => { - window.clearTimeout(timeout); - }; - }, [cells, hydrated, persistCells, sessionId]); - useEffect(() => { if (!currentCell || currentCell.status !== "draft") { return; @@ -395,22 +279,7 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean const setCurrentSource = useCallback( (source: string, clearHistoryBrowse = true) => { - setCells((prev) => { - if (prev.length === 0) { - return prev; - } - const lastIndex = prev.length - 1; - const target = prev[lastIndex]; - if (target.source === source) { - return prev; - } - const next = [...prev]; - next[lastIndex] = { - ...target, - source, - }; - return next; - }); + appConsoleData.setDraftSource(source); if (clearHistoryBrowse) { historyBrowseRef.current = { @@ -419,91 +288,74 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean }; } }, - [], + [appConsoleData], ); const browseHistory = useCallback( (direction: "previous" | "next") => { - setCells((prev) => { - if (prev.length === 0) { - return prev; - } - const draft = prev[prev.length - 1]; - if (draft.status !== "draft") { - return prev; - } + const draft = appConsoleData.getSnapshot().cells.at(-1); + if (!draft || draft.status !== "draft") { + return; + } - const history = prev - .filter((cell) => cell.status !== "draft") - .map((cell) => cell.source) - .filter((source) => source.trim() !== ""); + const history = getHistorySources(appConsoleData.getSnapshot().cells); - if (history.length === 0) { - return prev; - } + if (history.length === 0) { + return; + } - const state = historyBrowseRef.current; - if (direction === "previous") { - const nextIndex = - state.index === null ? 0 : Math.min(state.index + 1, history.length - 1); - const draftBuffer = state.index === null ? draft.source : state.draftBuffer; - const nextSource = history[history.length - 1 - nextIndex] ?? draft.source; - historyBrowseRef.current = { - index: nextIndex, - draftBuffer, - }; - if (nextSource === draft.source) { - return prev; - } - const next = [...prev]; - next[next.length - 1] = { - ...draft, - source: nextSource, - }; - return next; + const state = historyBrowseRef.current; + if (direction === "previous") { + const nextIndex = + state.index === null ? 0 : Math.min(state.index + 1, history.length - 1); + const draftBuffer = state.index === null ? draft.source : state.draftBuffer; + const nextSource = history[history.length - 1 - nextIndex] ?? draft.source; + historyBrowseRef.current = { + index: nextIndex, + draftBuffer, + }; + if (nextSource === draft.source) { + return; } + appConsoleData.setDraftSource(nextSource); + return; + } - if (state.index === null) { - return prev; - } + if (state.index === null) { + return; + } - const nextIndex = state.index - 1; - const nextSource = - nextIndex >= 0 - ? history[history.length - 1 - nextIndex] ?? draft.source - : state.draftBuffer; - - historyBrowseRef.current = - nextIndex >= 0 - ? { - index: nextIndex, - draftBuffer: state.draftBuffer, - } - : { - index: null, - draftBuffer: "", - }; + const nextIndex = state.index - 1; + const nextSource = + nextIndex >= 0 + ? history[history.length - 1 - nextIndex] ?? draft.source + : state.draftBuffer; - if (nextSource === draft.source) { - return prev; - } + historyBrowseRef.current = + nextIndex >= 0 + ? { + index: nextIndex, + draftBuffer: state.draftBuffer, + } + : { + index: null, + draftBuffer: "", + }; - const next = [...prev]; - next[next.length - 1] = { - ...draft, - source: nextSource, - }; - return next; - }); + if (nextSource === draft.source) { + return; + } + + appConsoleData.setDraftSource(nextSource); }, - [], + [appConsoleData], ); const executeCurrentCell = useCallback(async () => { - if (!currentCell || currentCell.status !== "draft") { - return; - } - if (currentCell.source.trim() === "") { + await appConsoleData.hydrate(); + + const execution = appConsoleData.startDraftExecution(); + if (!execution) { return; } @@ -512,48 +364,9 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean draftBuffer: "", }; - const startedAt = new Date().toISOString(); - const runningId = currentCell.id; - let stdout = ""; - let stderr = ""; - - const updateStreamingOutputs = (kind: "stdout" | "stderr", chunk: string) => { - if (kind === "stdout") { - stdout += chunk; - } else { - stderr += chunk; - } - - setCells((prev) => - prev.map((cell) => - cell.id === runningId - ? { - ...cell, - outputs: createStdTextOutputs(stdout, stderr), - } - : cell, - ), - ); - }; - - setCells((prev) => - prev.map((cell) => - cell.id === runningId - ? { - ...cell, - status: "running", - startedAt, - completedAt: undefined, - exitCode: undefined, - outputs: [], - } - : cell, - ), - ); - const globals = createAppJsGlobals({ runme, - sendOutput: (data) => updateStreamingOutputs("stdout", data), + sendOutput: (data) => appConsoleData.appendStdout(execution.cellId, data), resolveNotebookStore, ensureFilesystemStore, workspace: { @@ -593,39 +406,31 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean const kernel = new JSKernel({ globals, hooks: { - onStdout: (data) => updateStreamingOutputs("stdout", data), - onStderr: (data) => updateStreamingOutputs("stderr", data), + onStdout: (data) => appConsoleData.appendStdout(execution.cellId, data), + onStderr: (data) => appConsoleData.appendStderr(execution.cellId, data), }, }); - const { exitCode, result } = await kernel.run(currentCell.source); - const completedAt = new Date().toISOString(); - const nextDraft = createDraftCell(currentCell.index + 1); - const nextStatus: ConsoleCell["status"] = exitCode === 0 ? "success" : "error"; - - pendingFocusCellIdRef.current = nextDraft.id; - setCells((prev) => { - const next = prev.map((cell) => { - if (cell.id !== runningId) { - return cell; - } - return { - ...cell, - status: nextStatus, - completedAt, - exitCode, - outputs: [ - ...createStdTextOutputs(stdout, stderr), - ...createResultOutput(result), - ], - }; + try { + const { exitCode, result } = await kernel.run(execution.source); + appConsoleData.completeExecution(execution.cellId, { + exitCode, + result, }); - next.push(nextDraft); - return next; - }); + const nextDraft = appConsoleData.getSnapshot().cells.at(-1); + pendingFocusCellIdRef.current = + nextDraft?.status === "draft" ? nextDraft.id : null; + } catch (error) { + appConsoleData.failExecution(execution.cellId, { + message: String(error), + }); + const nextDraft = appConsoleData.getSnapshot().cells.at(-1); + pendingFocusCellIdRef.current = + nextDraft?.status === "draft" ? nextDraft.id : null; + } }, [ addItem, - currentCell, + appConsoleData, deleteRunner, ensureFilesystemStore, getItems, @@ -751,7 +556,7 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean disabled={currentCell?.status !== "draft"} className="rounded border border-white/15 px-2 py-1 text-[11px] font-medium text-slate-200 transition hover:border-sky-300/50 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-40" onClick={() => { - setCurrentSource(cell.source); + appConsoleData.copyCellSourceToDraft(cell.id); draftEditorRef.current?.focus?.(); }} > diff --git a/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx b/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx index 547c9a1..32dcedc 100644 --- a/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx +++ b/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx @@ -2,8 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, render } from "@testing-library/react"; -const { executeMock, appLoggerMock } = vi.hoisted(() => ({ +const { executeMock, appConsoleDataMock, appLoggerMock } = vi.hoisted(() => ({ executeMock: vi.fn(), + appConsoleDataMock: { + hydrate: vi.fn(), + startExternalExecution: vi.fn(), + appendStdout: vi.fn(), + appendStderr: vi.fn(), + completeExecution: vi.fn(), + failExecution: vi.fn(), + }, appLoggerMock: { debug: vi.fn(), info: vi.fn(), @@ -17,6 +25,10 @@ vi.mock("../../lib/runtime/useCodeModeExecutor", () => ({ }), })); +vi.mock("../../lib/appConsole/appConsoleController", () => ({ + getAppConsoleData: () => appConsoleDataMock, +})); + vi.mock("../../lib/logging/runtime", () => ({ appLogger: appLoggerMock, })); @@ -27,6 +39,17 @@ describe("WebMcpToolRegistrationHost", () => { beforeEach(() => { executeMock.mockReset(); executeMock.mockResolvedValue({ output: "webmcp output" }); + appConsoleDataMock.hydrate.mockReset(); + appConsoleDataMock.hydrate.mockResolvedValue(undefined); + appConsoleDataMock.startExternalExecution.mockReset(); + appConsoleDataMock.startExternalExecution.mockReturnValue({ + cellId: "cell-1", + source: "console.log('hello')", + }); + appConsoleDataMock.appendStdout.mockReset(); + appConsoleDataMock.appendStderr.mockReset(); + appConsoleDataMock.completeExecution.mockReset(); + appConsoleDataMock.failExecution.mockReset(); appLoggerMock.debug.mockReset(); appLoggerMock.info.mockReset(); appLoggerMock.error.mockReset(); @@ -104,13 +127,62 @@ describe("WebMcpToolRegistrationHost", () => { code: "console.log('hello')", }), ).resolves.toBe("webmcp output"); + expect(appConsoleDataMock.hydrate).toHaveBeenCalledTimes(1); + expect(appConsoleDataMock.startExternalExecution).toHaveBeenCalledWith( + "console.log('hello')", + ); expect(executeMock).toHaveBeenCalledWith({ code: "console.log('hello')", source: "webmcp", + hooks: { + onStdout: expect.any(Function), + onStderr: expect.any(Function), + }, }); + const executeArgs = executeMock.mock.calls[0]?.[0]; + executeArgs?.hooks?.onStdout?.("stdout chunk"); + executeArgs?.hooks?.onStderr?.("stderr chunk"); + expect(appConsoleDataMock.appendStdout).toHaveBeenCalledWith( + "cell-1", + "stdout chunk", + ); + expect(appConsoleDataMock.appendStderr).toHaveBeenCalledWith( + "cell-1", + "stderr chunk", + ); + expect(appConsoleDataMock.completeExecution).toHaveBeenCalledWith("cell-1", { + exitCode: 0, + }); + expect(appConsoleDataMock.failExecution).not.toHaveBeenCalled(); expect(registered?.signal?.aborted).toBe(false); rendered.unmount(); expect(registered?.signal?.aborted).toBe(true); }); + + it("marks the AppConsole cell failed when ExecuteCode rejects", async () => { + executeMock.mockRejectedValueOnce(new Error("boom")); + Object.defineProperty(navigator, "modelContext", { + configurable: true, + value: { + registerTool: vi.fn(), + }, + }); + + render(); + const registerTool = (navigator as Navigator & { + modelContext?: { registerTool: ReturnType }; + }).modelContext?.registerTool; + const registered = registerTool?.mock.calls[0]?.[0]; + + await expect( + registered?.execute({ + code: "console.log('hello')", + }), + ).rejects.toThrow("boom"); + + expect(appConsoleDataMock.failExecution).toHaveBeenCalledWith("cell-1", { + message: "boom", + }); + }); }); diff --git a/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx b/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx index bac0b68..80b8967 100644 --- a/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx +++ b/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { appLogger } from "../../lib/logging/runtime"; +import { getAppConsoleData } from "../../lib/appConsole/appConsoleController"; import { buildExecuteCodeInputSchema, EXECUTE_CODE_TOOL_DESCRIPTION, @@ -45,6 +46,7 @@ function getModelContext(): ModelContextLike | null { export default function WebMcpToolRegistrationHost() { const codeModeExecutor = useCodeModeExecutor({ mode: "sandbox" }); + const appConsoleData = getAppConsoleData(); useEffect(() => { const modelContext = getModelContext(); @@ -73,11 +75,40 @@ export default function WebMcpToolRegistrationHost() { execute: async (input) => { const code = typeof input?.code === "string" ? input.code : String(input?.code ?? ""); - const result = await codeModeExecutor.execute({ - code, - source: "webmcp", - }); - return result.output; + await appConsoleData.hydrate(); + + const execution = appConsoleData.startExternalExecution(code); + + try { + const result = await codeModeExecutor.execute({ + code, + source: "webmcp", + hooks: execution + ? { + onStdout: (chunk) => { + appConsoleData.appendStdout(execution.cellId, chunk); + }, + onStderr: (chunk) => { + appConsoleData.appendStderr(execution.cellId, chunk); + }, + } + : undefined, + }); + + if (execution) { + appConsoleData.completeExecution(execution.cellId, { + exitCode: 0, + }); + } + return result.output; + } catch (error) { + if (execution) { + appConsoleData.failExecution(execution.cellId, { + message: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } }, }, { @@ -110,7 +141,7 @@ export default function WebMcpToolRegistrationHost() { }, }); }; - }, [codeModeExecutor]); + }, [appConsoleData, codeModeExecutor]); return null; } diff --git a/app/src/lib/appConsole/AppConsoleData.test.ts b/app/src/lib/appConsole/AppConsoleData.test.ts new file mode 100644 index 0000000..d31ebae --- /dev/null +++ b/app/src/lib/appConsole/AppConsoleData.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; + +import type { + PersistedConsoleCellRow, + PersistedConsoleSessionRow, +} from "../../components/AppConsole/model"; +import { AppConsoleData } from "./AppConsoleData"; + +function decodeOutputs(rows: Array<{ items?: Array<{ data?: Uint8Array }> }>): string { + const decoder = new TextDecoder(); + return rows + .flatMap((row) => row.items ?? []) + .map((item) => decoder.decode(item?.data ?? new Uint8Array())) + .join(""); +} + +function createStorageStub() { + let session: PersistedConsoleSessionRow | null = null; + let cells: PersistedConsoleCellRow[] = []; + + return { + storage: { + async createSession(now = new Date().toISOString()) { + session = { + id: "session-1", + createdAt: now, + updatedAt: now, + }; + return session; + }, + async loadLatestSession() { + if (!session) { + return null; + } + return { + session, + cells, + }; + }, + async saveCells(rows: PersistedConsoleCellRow[]) { + cells = rows.map((row) => ({ ...row })); + }, + async touchSession(sessionId: string, updatedAt = new Date().toISOString()) { + session = sessionId + ? { + id: sessionId, + createdAt: session?.createdAt ?? updatedAt, + updatedAt, + } + : null; + }, + }, + getRows() { + return cells; + }, + }; +} + +describe("AppConsoleData", () => { + const instances: AppConsoleData[] = []; + + afterEach(() => { + instances.splice(0).forEach((instance) => instance.dispose()); + }); + + it("keeps the current draft while an external execution streams output", async () => { + const { storage, getRows } = createStorageStub(); + const data = new AppConsoleData({ + storage, + persistDelayMs: 0, + }); + instances.push(data); + + await data.hydrate(); + data.setDraftSource("user draft"); + + const execution = data.startExternalExecution("console.log('tool')"); + expect(execution).not.toBeNull(); + + const runningSnapshot = data.getSnapshot(); + expect(runningSnapshot.cells).toHaveLength(2); + expect(runningSnapshot.cells[0]?.status).toBe("running"); + expect(runningSnapshot.cells[0]?.source).toBe("console.log('tool')"); + expect(runningSnapshot.cells[1]?.status).toBe("draft"); + expect(runningSnapshot.cells[1]?.source).toBe("user draft"); + + data.appendStdout(execution!.cellId, "stdout\n"); + data.appendStderr(execution!.cellId, "stderr\n"); + data.completeExecution(execution!.cellId, { exitCode: 0 }); + + const completedSnapshot = data.getSnapshot(); + expect(completedSnapshot.cells).toHaveLength(2); + expect(completedSnapshot.cells[0]?.status).toBe("success"); + expect(decodeOutputs(completedSnapshot.cells[0]?.outputs ?? [])).toContain("stdout"); + expect(decodeOutputs(completedSnapshot.cells[0]?.outputs ?? [])).toContain("stderr"); + expect(completedSnapshot.cells[1]?.status).toBe("draft"); + expect(completedSnapshot.cells[1]?.source).toBe("user draft"); + + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(getRows()).toHaveLength(2); + }); +}); diff --git a/app/src/lib/appConsole/AppConsoleData.ts b/app/src/lib/appConsole/AppConsoleData.ts new file mode 100644 index 0000000..1e1af4d --- /dev/null +++ b/app/src/lib/appConsole/AppConsoleData.ts @@ -0,0 +1,444 @@ +import { + coerceRestoredCells, + createDraftCell, + createResultOutput, + createStdTextOutputs, + type ConsoleCell, +} from "../../components/AppConsole/model"; +import { + appConsoleStorage, + type AppConsoleStorageLike, +} from "../../components/AppConsole/storage"; + +type Listener = () => void; + +type OutputBuffers = { + stdout: string; + stderr: string; +}; + +export type AppConsoleSnapshot = { + sessionId: string | null; + hydrated: boolean; + loadError: string | null; + cells: ConsoleCell[]; +}; + +export type AppConsoleExecutionHandle = { + cellId: string; + source: string; +}; + +const DEFAULT_PERSIST_DELAY_MS = 150; + +function withTrailingNewline(message: string): string { + if (!message) { + return ""; + } + return message.endsWith("\n") ? message : `${message}\n`; +} + +export class AppConsoleData { + private readonly listeners = new Set(); + private readonly outputBuffers = new Map(); + private readonly storage: AppConsoleStorageLike; + private readonly persistDelayMs: number; + private persistTimer: ReturnType | null = null; + private hydratePromise: Promise | null = null; + private persistenceEnabled = true; + private snapshotCache: AppConsoleSnapshot = { + sessionId: null, + hydrated: false, + loadError: null, + cells: [createDraftCell(1)], + }; + + constructor(options?: { + storage?: AppConsoleStorageLike; + persistDelayMs?: number; + }) { + this.storage = options?.storage ?? appConsoleStorage; + this.persistDelayMs = options?.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getSnapshot(): AppConsoleSnapshot { + return this.snapshotCache; + } + + async hydrate(): Promise { + if (this.snapshotCache.hydrated) { + return; + } + if (this.hydratePromise) { + return this.hydratePromise; + } + + this.hydratePromise = this.doHydrate().finally(() => { + this.hydratePromise = null; + }); + return this.hydratePromise; + } + + setDraftSource(source: string): void { + this.updateCells((cells) => { + const draftIndex = this.findDraftIndex(cells); + if (draftIndex < 0) { + return [...cells, createDraftCell(cells.length + 1, source)]; + } + + const draft = cells[draftIndex]; + if (draft.source === source) { + return cells; + } + + const next = [...cells]; + next[draftIndex] = { + ...draft, + source, + }; + return next; + }); + } + + copyCellSourceToDraft(cellId: string): void { + const source = + this.snapshotCache.cells.find((cell) => cell.id === cellId)?.source ?? null; + if (source === null) { + return; + } + this.setDraftSource(source); + } + + startDraftExecution(): AppConsoleExecutionHandle | null { + const draft = this.getCurrentDraftCell(); + if (!draft || draft.source.trim() === "") { + return null; + } + + const startedAt = new Date().toISOString(); + this.outputBuffers.set(draft.id, { + stdout: "", + stderr: "", + }); + this.updateCells((cells) => + cells.map((cell) => + cell.id === draft.id + ? { + ...cell, + status: "running", + startedAt, + completedAt: undefined, + exitCode: undefined, + outputs: [], + } + : cell, + ), + ); + + return { + cellId: draft.id, + source: draft.source, + }; + } + + startExternalExecution(source: string): AppConsoleExecutionHandle | null { + if (source.trim() === "") { + return null; + } + + const startedAt = new Date().toISOString(); + const runningCell: ConsoleCell = { + ...createDraftCell(0, source), + status: "running", + startedAt, + outputs: [], + }; + this.outputBuffers.set(runningCell.id, { + stdout: "", + stderr: "", + }); + + this.updateCells((cells) => { + const draftIndex = this.findDraftIndex(cells); + if (draftIndex < 0) { + return this.reindexCells([ + ...cells, + runningCell, + createDraftCell(cells.length + 2), + ]); + } + + const next = [...cells]; + next.splice(draftIndex, 0, runningCell); + return this.reindexCells(next); + }); + + return { + cellId: runningCell.id, + source, + }; + } + + appendStdout(cellId: string, chunk: string): void { + this.appendOutput(cellId, "stdout", chunk); + } + + appendStderr(cellId: string, chunk: string): void { + this.appendOutput(cellId, "stderr", chunk); + } + + completeExecution( + cellId: string, + result: { exitCode: number; result?: unknown }, + ): void { + const buffers = this.outputBuffers.get(cellId) ?? { stdout: "", stderr: "" }; + this.outputBuffers.delete(cellId); + const completedAt = new Date().toISOString(); + const nextStatus: ConsoleCell["status"] = + result.exitCode === 0 ? "success" : "error"; + + this.updateCells((cells) => { + const next = cells.map((cell) => + cell.id === cellId + ? { + ...cell, + status: nextStatus, + completedAt, + exitCode: result.exitCode, + outputs: [ + ...createStdTextOutputs(buffers.stdout, buffers.stderr), + ...createResultOutput(result.result), + ], + } + : cell, + ); + + return this.ensureTrailingDraft(next); + }); + } + + failExecution( + cellId: string, + error: { exitCode?: number; message?: string }, + ): void { + const current = this.outputBuffers.get(cellId) ?? { stdout: "", stderr: "" }; + const stderr = `${current.stderr}${withTrailingNewline(error.message ?? "")}`; + this.outputBuffers.delete(cellId); + const completedAt = new Date().toISOString(); + + this.updateCells((cells) => { + const next = cells.map((cell) => + cell.id === cellId + ? { + ...cell, + status: "error" as const, + completedAt, + exitCode: error.exitCode ?? 1, + outputs: createStdTextOutputs(current.stdout, stderr), + } + : cell, + ); + + return this.ensureTrailingDraft(next); + }); + } + + dispose(): void { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + this.listeners.clear(); + this.outputBuffers.clear(); + } + + private async doHydrate(): Promise { + const now = new Date().toISOString(); + try { + const restored = await this.storage.loadLatestSession(); + if (!restored) { + const session = await this.storage.createSession(now); + const nextCells = this.ensureTrailingDraft(this.snapshotCache.cells); + await this.persistRows(nextCells, session.id, now); + this.replaceSnapshot({ + sessionId: session.id, + hydrated: true, + loadError: null, + cells: nextCells, + }); + return; + } + + const recovered = coerceRestoredCells(restored.cells, now); + if (recovered.mutated) { + await this.persistRows(recovered.cells, restored.session.id, now); + } + + this.replaceSnapshot({ + sessionId: restored.session.id, + hydrated: true, + loadError: null, + cells: this.reindexCells(recovered.cells), + }); + } catch (error) { + console.error("Failed to restore App Console session", error); + this.persistenceEnabled = false; + this.replaceSnapshot({ + sessionId: + globalThis.crypto?.randomUUID?.() ?? `app-console-fallback-${Date.now()}`, + hydrated: true, + loadError: "Console history is unavailable for this session.", + cells: [createDraftCell(1)], + }); + } + } + + private appendOutput( + cellId: string, + kind: keyof OutputBuffers, + chunk: string, + ): void { + if (!chunk) { + return; + } + + const current = this.outputBuffers.get(cellId) ?? { stdout: "", stderr: "" }; + const nextBuffers: OutputBuffers = + kind === "stdout" + ? { + stdout: `${current.stdout}${chunk}`, + stderr: current.stderr, + } + : { + stdout: current.stdout, + stderr: `${current.stderr}${chunk}`, + }; + + this.outputBuffers.set(cellId, nextBuffers); + this.updateCells((cells) => + cells.map((cell) => + cell.id === cellId + ? { + ...cell, + outputs: createStdTextOutputs(nextBuffers.stdout, nextBuffers.stderr), + } + : cell, + ), + ); + } + + private getCurrentDraftCell(): ConsoleCell | null { + const cells = this.snapshotCache.cells; + const draft = cells[cells.length - 1]; + return draft?.status === "draft" ? draft : null; + } + + private findDraftIndex(cells: ConsoleCell[]): number { + return cells.findIndex((cell) => cell.status === "draft"); + } + + private reindexCells(cells: ConsoleCell[]): ConsoleCell[] { + return cells.map((cell, index) => ({ + ...cell, + index: index + 1, + })); + } + + private ensureTrailingDraft(cells: ConsoleCell[]): ConsoleCell[] { + const draftIndex = this.findDraftIndex(cells); + if (draftIndex >= 0) { + const ordered = this.reindexCells(cells); + const draft = ordered[draftIndex]; + if (draftIndex === ordered.length - 1) { + return ordered; + } + + const next = [...ordered]; + next.splice(draftIndex, 1); + next.push(draft); + return this.reindexCells(next); + } + + return this.reindexCells([...cells, createDraftCell(cells.length + 1)]); + } + + private updateCells( + updater: (cells: ConsoleCell[]) => ConsoleCell[], + options?: { persist?: boolean }, + ): void { + const nextCells = updater(this.snapshotCache.cells); + if (nextCells === this.snapshotCache.cells) { + return; + } + + this.replaceSnapshot({ + ...this.snapshotCache, + cells: nextCells, + }); + + if (options?.persist !== false) { + this.schedulePersist(); + } + } + + private replaceSnapshot(next: AppConsoleSnapshot): void { + this.snapshotCache = next; + this.emit(); + } + + private emit(): void { + this.listeners.forEach((listener) => { + try { + listener(); + } catch (error) { + console.error("AppConsoleData listener failed", error); + } + }); + } + + private schedulePersist(): void { + if (!this.persistenceEnabled || !this.snapshotCache.sessionId) { + return; + } + if (this.persistTimer) { + clearTimeout(this.persistTimer); + } + + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + void this.persist().catch((error) => { + console.error("Failed to persist App Console state", error); + }); + }, this.persistDelayMs); + } + + private async persist(): Promise { + const sessionId = this.snapshotCache.sessionId; + if (!this.persistenceEnabled || !sessionId) { + return; + } + await this.persistRows(this.snapshotCache.cells, sessionId, new Date().toISOString()); + } + + private async persistRows( + cells: ConsoleCell[], + sessionId: string, + updatedAt: string, + ): Promise { + await this.storage.saveCells( + cells.map((cell) => ({ + ...cell, + sessionId, + updatedAt, + })), + ); + await this.storage.touchSession(sessionId, updatedAt); + } +} diff --git a/app/src/lib/appConsole/appConsoleController.ts b/app/src/lib/appConsole/appConsoleController.ts new file mode 100644 index 0000000..7c2e7e3 --- /dev/null +++ b/app/src/lib/appConsole/appConsoleController.ts @@ -0,0 +1,15 @@ +import { AppConsoleData } from "./AppConsoleData"; + +let appConsoleDataSingleton: AppConsoleData | null = null; + +export function getAppConsoleData(): AppConsoleData { + if (!appConsoleDataSingleton) { + appConsoleDataSingleton = new AppConsoleData(); + } + return appConsoleDataSingleton; +} + +export function __resetAppConsoleDataForTests(): void { + appConsoleDataSingleton?.dispose(); + appConsoleDataSingleton = null; +} diff --git a/app/src/lib/appConsole/useAppConsoleSnapshot.ts b/app/src/lib/appConsole/useAppConsoleSnapshot.ts new file mode 100644 index 0000000..8804432 --- /dev/null +++ b/app/src/lib/appConsole/useAppConsoleSnapshot.ts @@ -0,0 +1,18 @@ +import { useEffect, useSyncExternalStore } from "react"; + +import { getAppConsoleData } from "./appConsoleController"; +import type { AppConsoleSnapshot } from "./AppConsoleData"; + +export function useAppConsoleSnapshot(): AppConsoleSnapshot { + const appConsoleData = getAppConsoleData(); + + useEffect(() => { + void appConsoleData.hydrate(); + }, [appConsoleData]); + + return useSyncExternalStore( + (listener) => appConsoleData.subscribe(listener), + () => appConsoleData.getSnapshot(), + () => appConsoleData.getSnapshot(), + ); +} diff --git a/app/src/lib/runtime/codeModeExecutor.test.ts b/app/src/lib/runtime/codeModeExecutor.test.ts index 72419e1..cfb5c6f 100644 --- a/app/src/lib/runtime/codeModeExecutor.test.ts +++ b/app/src/lib/runtime/codeModeExecutor.test.ts @@ -80,6 +80,31 @@ describe('codeModeExecutor', () => { expect(result.output).toContain('[output truncated]') }) + it('forwards streaming chunks to execution hooks', async () => { + const notebook = createNotebook() + const onStdout = vi.fn() + const onStderr = vi.fn() + const executor = createCodeModeExecutor({ + mode: 'browser', + resolveNotebook: () => notebook, + listNotebooks: () => [notebook], + }) + + const result = await executor.execute({ + source: 'webmcp', + code: "console.log('one'); console.error('two');", + hooks: { + onStdout, + onStderr, + }, + }) + + expect(result.output).toContain('one') + expect(result.output).toContain('two') + expect(onStdout).toHaveBeenCalled() + expect(onStderr).toHaveBeenCalled() + }) + it('returns partial output when execution times out', async () => { const notebook = createNotebook() const executor = createCodeModeExecutor({ diff --git a/app/src/lib/runtime/codeModeExecutor.ts b/app/src/lib/runtime/codeModeExecutor.ts index 67de041..4d1657d 100644 --- a/app/src/lib/runtime/codeModeExecutor.ts +++ b/app/src/lib/runtime/codeModeExecutor.ts @@ -26,6 +26,10 @@ const DEFAULT_MAX_CODE_BYTES = 64 * 1024 const OUTPUT_TRUNCATED_SUFFIX = '\n[output truncated]\n' export type CodeModeExecutionError = Error & { output: string } +export type CodeModeExecutionHooks = { + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void +} function withOutput(error: unknown, output: string): CodeModeExecutionError { const err = error instanceof Error ? error : new Error(String(error)) @@ -46,6 +50,7 @@ export type CodeModeExecutor = { execute(args: { code: string source: CodeModeSource + hooks?: CodeModeExecutionHooks }): Promise<{ output: string }> } @@ -71,7 +76,7 @@ export function createCodeModeExecutor(options: { }) return { - execute: async ({ code, source }) => { + execute: async ({ code, source, hooks }) => { const normalizedCode = typeof code === 'string' ? code : String(code ?? '') const codeBytes = new TextEncoder().encode(normalizedCode).length @@ -135,7 +140,10 @@ export function createCodeModeExecutor(options: { const globals = createAppJsGlobals({ runme: runmeApi, - sendOutput: appendOutput, + sendOutput: (data) => { + appendOutput(data) + hooks?.onStdout?.(data) + }, resolveNotebook, listNotebooks, opfsApi, @@ -147,8 +155,14 @@ export function createCodeModeExecutor(options: { mode === 'sandbox' ? new SandboxJSKernel({ hooks: { - onStdout: appendOutput, - onStderr: appendOutput, + onStdout: (data) => { + appendOutput(data) + hooks?.onStdout?.(data) + }, + onStderr: (data) => { + appendOutput(data) + hooks?.onStderr?.(data) + }, }, allowedMethods: CODE_MODE_SANDBOX_ALLOWED_METHODS, bridge: { @@ -166,8 +180,14 @@ export function createCodeModeExecutor(options: { : new JSKernel({ globals, hooks: { - onStdout: appendOutput, - onStderr: appendOutput, + onStdout: (data) => { + appendOutput(data) + hooks?.onStdout?.(data) + }, + onStderr: (data) => { + appendOutput(data) + hooks?.onStderr?.(data) + }, }, }).run(normalizedCode) diff --git a/docs-dev/design/20260510_app_console_model_view.md b/docs-dev/design/20260510_app_console_model_view.md new file mode 100644 index 0000000..e76d9ea --- /dev/null +++ b/docs-dev/design/20260510_app_console_model_view.md @@ -0,0 +1,406 @@ +# 20260510 App Console Model-View Refactor + +## Status + +Draft proposal. + +## Summary + +Refactor App Console from a stateful React component into a model-view +architecture aligned with the existing notebook document model. + +The current UI already persists console cells in IndexedDB, but the live source +of truth still lives inside `AppConsole.tsx` as local React state. That makes +it difficult for non-UI producers such as WebMCP `ExecuteCode` to append cells +and stream outputs into the mounted console. + +We should introduce an `AppConsoleData` model that owns: + +- console cells +- the active session id +- draft/running/completed cell transitions +- persistence +- subscriptions and snapshots for React + +`AppConsole.tsx` should become a view over that model, not the owner of its +state. + +## Problem + +The current App Console has three conflicting roles: + +1. UI rendering +2. execution orchestration +3. session persistence and state ownership + +That works for user-triggered console execution inside one mounted component, +but it does not scale to multiple producers. + +The concrete problem is external execution sources: + +- WebMCP `ExecuteCode` +- future ChatKit or Codex code-mode logging +- possible programmatic “replay in console” flows + +Those code paths can write to IndexedDB, but the mounted `AppConsole` will not +react because it does not subscribe to external storage changes. It only: + +- hydrates once from `appConsoleStorage.loadLatestSession()` +- keeps the live transcript in `useState(...)` +- writes snapshots back with `saveCells(...)` + +That means persistence exists, but shared live state does not. + +## Relevant Current State + +The current implementation is in: + +- `app/src/components/AppConsole/AppConsole.tsx` +- `app/src/components/AppConsole/model.ts` +- `app/src/components/AppConsole/storage.ts` + +What is already good: + +- append-only cell UX already exists +- `ConsoleCell` and persisted row types already exist +- stdout/stderr/result output rendering already reuses `parser_pb.CellOutput[]` +- IndexedDB persistence already exists through Dexie + +What is not yet aligned with the rest of the app: + +- `cells` live in React component state +- session hydration logic lives in the component +- persistence debounce lives in the component +- history navigation state lives in the component +- execution state transitions live in the component +- external code cannot append cells through a shared runtime model + +## Goals + +- Make App Console state shareable across UI and non-UI producers. +- Align App Console with the repo’s model-view direction used by notebook data. +- Preserve the current append-only console UX. +- Allow external execution sources to create cells and stream outputs live. +- Keep persistence centralized and automatic. +- Make React a subscriber and renderer, not the owner of console state. + +## Non-Goals + +- Replacing the App Console cell UX again. +- Turning App Console into a first-class notebook document. +- Supporting collaborative multi-tab conflict resolution in v0. +- Replacing `parser_pb.CellOutput[]` with a new output schema. +- Solving every code execution path in the same change. + +## Decision + +We should rearchitect App Console to a model-view architecture. + +More specifically: + +- introduce an `AppConsoleData` model object +- move live transcript ownership out of `AppConsole.tsx` +- expose snapshots and subscriptions via `useSyncExternalStore` +- keep Dexie persistence as a storage backend, not the live state owner +- let App Console UI, WebMCP, and future producers mutate the same in-memory + model through explicit APIs + +This is the same architectural move we already made for notebook documents. + +## Why Not Just Subscribe To Dexie + +Dexie `liveQuery` or an IndexedDB event bus could make the UI react to external +writes, but that should not be the primary architecture. + +Reasons: + +- execution wants low-latency streaming updates, not storage-driven polling +- persistence is a side effect, not the domain model +- we need explicit state transitions such as `draft -> running -> success` +- React should not infer intent from database rows alone +- notebook data in this repo already treats persistence and live ownership as + separate concerns + +A storage subscription may still be useful later for cross-tab sync, but it +should not replace a proper model. + +## Proposed Architecture + +### Core model + +Add a dedicated model, for example: + +- `app/src/lib/appConsole/AppConsoleData.ts` + +Recommended responsibilities: + +- own `sessionId` +- own the ordered `ConsoleCell[]` +- own history-browse metadata if it must survive rerenders +- emit change events +- produce immutable snapshots for React +- persist state to storage +- expose mutation methods for execution producers + +Recommended shape: + +```ts +type AppConsoleSnapshot = { + sessionId: string | null; + hydrated: boolean; + loadError: string | null; + cells: ConsoleCell[]; +}; + +class AppConsoleData { + subscribe(listener: () => void): () => void; + getSnapshot(): AppConsoleSnapshot; + + hydrate(): Promise; + setCollapsed(value: boolean): void; + + setDraftSource(source: string): void; + copyCellSourceToDraft(cellId: string): void; + + startExecution(source: string, options?: { producer?: string }): { cellId: string }; + appendStdout(cellId: string, chunk: string): void; + appendStderr(cellId: string, chunk: string): void; + completeExecution(cellId: string, result: { exitCode: number; result?: unknown }): void; + failExecution(cellId: string, error: { exitCode?: number; message?: string }): void; +} +``` + +### React integration + +Add a small hook: + +```ts +function useAppConsoleSnapshot(): AppConsoleSnapshot; +``` + +This hook should mirror the notebook pattern: + +- subscribe with `useSyncExternalStore` +- return a stable snapshot +- avoid React-local duplication of the transcript + +`AppConsole.tsx` should then become mostly: + +- UI layout +- editor callbacks +- run button / history button wiring +- rendering snapshot cells + +### Persistence layer + +Keep Dexie storage in `app/src/components/AppConsole/storage.ts`, but demote it +to a backend for the model. + +The model should: + +- hydrate from storage once +- persist after mutations, ideally debounced +- touch the session row on every meaningful transcript change +- recover interrupted `running` cells during hydration + +The storage API can remain close to what exists today: + +- `createSession(...)` +- `loadLatestSession()` +- `saveCells(...)` +- `touchSession(...)` + +The main change is ownership, not schema. + +### Execution producers + +The model should support multiple producers writing to the same transcript. + +Examples: + +- App Console UI execution +- WebMCP `ExecuteCode` +- future ChatKit “show execution in console” paths + +To make that work, producer code should not call `setCells(...)` directly. It +should call model methods such as: + +- `startExecution(...)` +- `appendStdout(...)` +- `appendStderr(...)` +- `completeExecution(...)` + +## Execution Integration + +The current UI-driven execution path in `AppConsole.tsx` should be split into: + +1. model updates +2. kernel orchestration + +Suggested execution flow: + +1. UI asks model to start execution for the current draft source +2. model freezes the draft cell into `running` +3. UI or helper creates `JSKernel` +4. kernel stdout/stderr callbacks call model append methods +5. completion callback calls `completeExecution(...)` +6. model appends a new draft cell + +That same flow can be reused by WebMCP with a different producer label. + +## WebMCP Integration + +Once the model exists, WebMCP should not write to Dexie directly. + +Instead: + +1. `WebMcpToolRegistrationHost` calls a shared app-console execution helper or + the model directly +2. the model creates a running console cell +3. `CodeModeExecutor` streams output into that cell +4. the model finalizes the cell on success or failure + +This gives us: + +- live UI updates while the tool runs +- persisted history after completion +- one transcript regardless of whether execution came from the App Console UI + or WebMCP + +## Streaming Output Gap + +One implementation gap still exists in `codeModeExecutor.ts`. + +Today it returns only the final merged output string: + +```ts +Promise<{ output: string }> +``` + +For App Console integration, it should optionally surface streaming callbacks: + +```ts +type CodeModeExecutionHooks = { + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; +}; + +execute(args: { + code: string; + source: CodeModeSource; + hooks?: CodeModeExecutionHooks; +}): Promise<{ output: string }>; +``` + +The executor already has the internal chunk boundaries. It just needs to expose +them. + +## Suggested File Layout + +Recommended new files: + +- `app/src/lib/appConsole/AppConsoleData.ts` +- `app/src/lib/appConsole/appConsoleController.ts` +- `app/src/lib/appConsole/useAppConsoleSnapshot.ts` + +Recommended files to simplify: + +- `app/src/components/AppConsole/AppConsole.tsx` + +Existing files to retain with minimal shape changes: + +- `app/src/components/AppConsole/model.ts` +- `app/src/components/AppConsole/storage.ts` + +We can keep the UI files in `components/AppConsole/` and the runtime/data files +under `lib/appConsole/` to mirror how notebook data lives outside the view. + +## Phased Plan + +### Phase 1: Extract ownership + +- introduce `AppConsoleData` +- move hydration and persistence out of `AppConsole.tsx` +- make React read snapshots from the model +- keep UI-driven execution behavior unchanged + +### Phase 2: Extract execution helpers + +- move App Console execution orchestration into a reusable helper +- add streaming hooks to `CodeModeExecutor` +- keep the App Console UI using that helper + +### Phase 3: Add external producers + +- wire WebMCP `ExecuteCode` into the App Console model +- append a console cell for each tool execution +- stream tool outputs live into the console + +### Phase 4: Optional cross-tab behavior + +- if needed, add storage subscriptions or BroadcastChannel sync +- keep this separate from the core model/view refactor + +## Risks + +### Risk: too much churn in one refactor + +Mitigation: + +- do Phase 1 first without WebMCP integration +- keep the existing cell UI and storage schema where possible + +### Risk: duplicate draft cells or inconsistent indices + +Mitigation: + +- keep one model owner for all mutations +- centralize “append next draft cell” logic in the model + +### Risk: persistence races + +Mitigation: + +- keep debounced persistence inside the model +- persist full ordered rows rather than partial row deltas in v0 + +### Risk: overcoupling model to React + +Mitigation: + +- keep React hooks thin +- put all business logic into the model/controller layer + +## Open Questions + +- Should collapsed state also move into the App Console model, or stay as a + simple component-local/localStorage concern? +- Should history-browse state live in the model, or remain ephemeral UI state? +- Do we want a singleton global App Console session, or one session per + workspace/project in the future? +- Should WebMCP executions always appear in App Console, or should that be a + configurable behavior? + +## Recommendation + +Yes, the right way to support WebMCP-driven console cells is to rearchitect +App Console to model-view. + +The narrow implementation recommendation is: + +- introduce `AppConsoleData` +- make `AppConsole.tsx` a view over that model +- keep Dexie persistence as a storage backend +- expose execution mutation APIs for WebMCP and other producers + +That gives us a coherent architecture instead of treating IndexedDB as an +accidental synchronization mechanism. + +## References + +- `app/design.md` +- `app/src/components/AppConsole/AppConsole.tsx` +- `app/src/components/AppConsole/model.ts` +- `app/src/components/AppConsole/storage.ts` +- `docs-dev/design/20260502_app_console_cells.md` +- `docs-dev/design/20260510_webmcp.md` diff --git a/docs-dev/index.md b/docs-dev/index.md index f8d5747..e152e37 100644 --- a/docs-dev/index.md +++ b/docs-dev/index.md @@ -15,6 +15,7 @@ This directory organizes implementation-facing documentation. - CUJ: `docs-dev/CUJs/copy-current-notebook-to-drive.md` - Drive design: `docs-dev/design/20260224_drive.md` +- App Console model-view design: `docs-dev/design/20260510_app_console_model_view.md` - WebMCP design: `docs-dev/design/20260510_webmcp.md` - Configuration architecture: `docs-dev/architecture/configuration.md` - Drive testing strategy: `testing.md` From b79326b4544558811946078eb4cf3839478c8209 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sun, 10 May 2026 14:27:15 -0700 Subject: [PATCH 2/3] Cap bottom pane height Signed-off-by: Jeremy lewi --- app/src/components/AppConsole/AppConsole.tsx | 6 +++--- .../components/BottomPane/BottomPane.test.tsx | 5 ++++- app/src/components/BottomPane/BottomPane.tsx | 19 ++++++++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/src/components/AppConsole/AppConsole.tsx b/app/src/components/AppConsole/AppConsole.tsx index 58ef61f..166fb52 100644 --- a/app/src/components/AppConsole/AppConsole.tsx +++ b/app/src/components/AppConsole/AppConsole.tsx @@ -479,7 +479,7 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean return (
{showHeader && (
{loadError ? (
diff --git a/app/src/components/BottomPane/BottomPane.test.tsx b/app/src/components/BottomPane/BottomPane.test.tsx index 093ec9f..6e1efe3 100644 --- a/app/src/components/BottomPane/BottomPane.test.tsx +++ b/app/src/components/BottomPane/BottomPane.test.tsx @@ -31,10 +31,13 @@ describe("BottomPane", () => { expect(logsTab.className).toContain("data-[state=active]:shadow"); const toggle = screen.getByRole("button", { name: "Collapse bottom pane" }); + const pane = screen.getByRole("tablist").closest("#bottom-pane"); + + expect(pane?.className).toContain("h-[30vh]"); fireEvent.click(toggle); expect(screen.getByRole("button", { name: "Expand bottom pane" })).toBeTruthy(); - const pane = screen.getByRole("tablist").closest("#bottom-pane"); expect(pane?.getAttribute("data-collapsed")).toBe("true"); + expect(pane?.className).not.toContain("h-[30vh]"); }); }); diff --git a/app/src/components/BottomPane/BottomPane.tsx b/app/src/components/BottomPane/BottomPane.tsx index 6233b4e..40c9149 100644 --- a/app/src/components/BottomPane/BottomPane.tsx +++ b/app/src/components/BottomPane/BottomPane.tsx @@ -40,9 +40,15 @@ export default function BottomPane() {
- +
-
+
{/* Keep both panes mounted so terminal/log state is preserved while switching tabs, similar to VS Code's bottom panel behavior. @@ -86,7 +95,7 @@ export default function BottomPane() { id="bottom-pane-content-console" value="console" forceMount - className="min-h-[220px] data-[state=inactive]:hidden" + className="h-full min-h-0 flex-1 data-[state=inactive]:hidden" > @@ -94,7 +103,7 @@ export default function BottomPane() { id="bottom-pane-content-logs" value="logs" forceMount - className="min-h-[220px] data-[state=inactive]:hidden" + className="h-full min-h-0 flex-1 data-[state=inactive]:hidden" > From 66de1507a2e3c368b48288d16213a180515b9b0c Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Mon, 11 May 2026 13:02:59 -0700 Subject: [PATCH 3/3] Use ExecuteCode exit codes for WebMCP console cells Signed-off-by: Jeremy lewi --- .../WebMcpToolRegistrationHost.test.tsx | 32 ++++++++++++++++++- .../WebMcp/WebMcpToolRegistrationHost.tsx | 2 +- app/src/lib/runtime/codeModeExecutor.ts | 10 +++++- .../responsesDirectChatKitAdapter.test.ts | 6 ++-- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx b/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx index 32dcedc..95959a4 100644 --- a/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx +++ b/app/src/components/WebMcp/WebMcpToolRegistrationHost.test.tsx @@ -38,7 +38,7 @@ import WebMcpToolRegistrationHost from "./WebMcpToolRegistrationHost"; describe("WebMcpToolRegistrationHost", () => { beforeEach(() => { executeMock.mockReset(); - executeMock.mockResolvedValue({ output: "webmcp output" }); + executeMock.mockResolvedValue({ output: "webmcp output", exitCode: 0 }); appConsoleDataMock.hydrate.mockReset(); appConsoleDataMock.hydrate.mockResolvedValue(undefined); appConsoleDataMock.startExternalExecution.mockReset(); @@ -185,4 +185,34 @@ describe("WebMcpToolRegistrationHost", () => { message: "boom", }); }); + + it("uses the resolved ExecuteCode exit code when finalizing the AppConsole cell", async () => { + executeMock.mockResolvedValueOnce({ + output: "runtime error output", + exitCode: 1, + }); + Object.defineProperty(navigator, "modelContext", { + configurable: true, + value: { + registerTool: vi.fn(), + }, + }); + + render(); + const registerTool = (navigator as Navigator & { + modelContext?: { registerTool: ReturnType }; + }).modelContext?.registerTool; + const registered = registerTool?.mock.calls[0]?.[0]; + + await expect( + registered?.execute({ + code: "throw new Error('boom')", + }), + ).resolves.toBe("runtime error output"); + + expect(appConsoleDataMock.completeExecution).toHaveBeenCalledWith("cell-1", { + exitCode: 1, + }); + expect(appConsoleDataMock.failExecution).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx b/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx index 80b8967..08d0fd0 100644 --- a/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx +++ b/app/src/components/WebMcp/WebMcpToolRegistrationHost.tsx @@ -97,7 +97,7 @@ export default function WebMcpToolRegistrationHost() { if (execution) { appConsoleData.completeExecution(execution.cellId, { - exitCode: 0, + exitCode: result.exitCode, }); } return result.output; diff --git a/app/src/lib/runtime/codeModeExecutor.ts b/app/src/lib/runtime/codeModeExecutor.ts index 4d1657d..41dbe54 100644 --- a/app/src/lib/runtime/codeModeExecutor.ts +++ b/app/src/lib/runtime/codeModeExecutor.ts @@ -51,7 +51,7 @@ export type CodeModeExecutor = { code: string source: CodeModeSource hooks?: CodeModeExecutionHooks - }): Promise<{ output: string }> + }): Promise<{ output: string; exitCode: number }> } export function createCodeModeExecutor(options: { @@ -151,6 +151,7 @@ export function createCodeModeExecutor(options: { }) const abortController = new AbortController() + let finalExitCode = 0 const kernelRun = mode === 'sandbox' ? new SandboxJSKernel({ @@ -163,6 +164,9 @@ export function createCodeModeExecutor(options: { appendOutput(data) hooks?.onStderr?.(data) }, + onExit: (exitCode) => { + finalExitCode = exitCode + }, }, allowedMethods: CODE_MODE_SANDBOX_ALLOWED_METHODS, bridge: { @@ -188,6 +192,9 @@ export function createCodeModeExecutor(options: { appendOutput(data) hooks?.onStderr?.(data) }, + onExit: (exitCode) => { + finalExitCode = exitCode + }, }, }).run(normalizedCode) @@ -238,6 +245,7 @@ export function createCodeModeExecutor(options: { return { output, + exitCode: finalExitCode, } }, } diff --git a/app/src/lib/runtime/responsesDirectChatKitAdapter.test.ts b/app/src/lib/runtime/responsesDirectChatKitAdapter.test.ts index 7a3d93f..3a3a4e0 100644 --- a/app/src/lib/runtime/responsesDirectChatKitAdapter.test.ts +++ b/app/src/lib/runtime/responsesDirectChatKitAdapter.test.ts @@ -242,7 +242,7 @@ describe("responsesDirectChatKitAdapter", () => { it("executes ExecuteCode internally and continues the Responses turn", async () => { const codeModeExecutor = { - execute: vi.fn(async () => ({ output: "tool output" })), + execute: vi.fn(async () => ({ output: "tool output", exitCode: 0 })), }; const fetchMock = vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce( @@ -324,7 +324,7 @@ describe("responsesDirectChatKitAdapter", () => { it("falls back call_id to item_id only when item_id already looks like a call id", async () => { const codeModeExecutor = { - execute: vi.fn(async () => ({ output: "ok" })), + execute: vi.fn(async () => ({ output: "ok", exitCode: 0 })), }; const fetchMock = vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce( @@ -377,7 +377,7 @@ describe("responsesDirectChatKitAdapter", () => { it("recovers call_id from function_call output item when arguments.done omits call_id", async () => { const codeModeExecutor = { - execute: vi.fn(async () => ({ output: "ok" })), + execute: vi.fn(async () => ({ output: "ok", exitCode: 0 })), }; const fetchMock = vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce(