diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index 43324be..762f081 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -37,6 +37,7 @@ import { useCurrentDoc } from "../../contexts/CurrentDocContext"; import { useRunners } from "../../contexts/RunnersContext"; import { DEFAULT_RUNNER_PLACEHOLDER } from "../../lib/runtime/runnersManager"; import React from "react"; +import { hasVisibleCellOutput } from "./outputVisibility"; type TabPanelProps = React.HTMLAttributes & { "data-state"?: "active" | "inactive"; @@ -472,7 +473,7 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo // return rendered; // } - if (!runID && (cell?.outputs?.length ?? 0) === 0) { + if (!runID && !hasVisibleCellOutput(cell?.outputs)) { return null; } diff --git a/app/src/components/Actions/WebContainer.test.tsx b/app/src/components/Actions/WebContainer.test.tsx new file mode 100644 index 0000000..3e59e8e --- /dev/null +++ b/app/src/components/Actions/WebContainer.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { act, render, screen } from "@testing-library/react"; +import { create } from "@bufbuild/protobuf"; +import { describe, expect, it, vi } from "vitest"; + +import { parser_pb } from "../../runme/client"; +import WebContainer from "./WebContainer"; + +const updateCell = vi.fn(); + +vi.mock("../../contexts/NotebookContext", () => ({ + useNotebookContext: () => ({ + getNotebookData: () => ({ updateCell }), + }), +})); + +vi.mock("../../contexts/CurrentDocContext", () => ({ + useCurrentDoc: () => ({ + getCurrentDoc: () => "notebook://test", + }), +})); + +vi.mock("../../lib/useAisreClient", () => ({ + useBaseUrl: () => "http://localhost:3000", +})); + +describe("WebContainer", () => { + it("keeps the render container mounted while toggling visibility", async () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-wc", + value: "await aisre.render((selection) => { selection.append('div').text('chart-ready'); });", + metadata: {}, + outputs: [], + }); + + render(); + + const shell = document.getElementById("webcontainer-output-shell-cell-wc"); + const before = document.getElementById("webcontainer-output-content-cell-wc"); + expect(shell).toBeTruthy(); + expect(shell?.className).toContain("hidden"); + expect(before).toBeTruthy(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent("runCodeCell", { detail: { cellId: "cell-wc" } }), + ); + await Promise.resolve(); + }); + + const after = document.getElementById("webcontainer-output-content-cell-wc"); + expect(after).toBe(before); + expect(shell?.className).not.toContain("hidden"); + expect(screen.getByText("chart-ready")).toBeTruthy(); + expect(updateCell).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/Actions/WebContainer.tsx b/app/src/components/Actions/WebContainer.tsx index 5891e4a..cc3311f 100644 --- a/app/src/components/Actions/WebContainer.tsx +++ b/app/src/components/Actions/WebContainer.tsx @@ -28,7 +28,7 @@ const toPrintable = (value: unknown) => { } try { return JSON.stringify(value); - } catch (err) { + } catch { return String(value); } }; @@ -46,6 +46,7 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { const [stdout, setStdout] = useState(""); const [stderr, setStderr] = useState(""); const [lastRunId, setLastRunId] = useState(0); + const [hasRenderableOutput, setHasRenderableOutput] = useState(false); const activeRunIdRef = useRef(null); const runCode = useCallback(async () => { @@ -60,6 +61,7 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { setLastRunId(runId); setStdout(""); setStderr(""); + setHasRenderableOutput(false); container.innerHTML = ""; onPid(null); @@ -79,19 +81,19 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { const mockedConsole = { log: (...args: unknown[]) => { - originalConsole.log(...(args as any[])); + originalConsole.log(...args); append(logBuffer, args); }, info: (...args: unknown[]) => { - originalConsole.info(...(args as any[])); + originalConsole.info(...args); append(logBuffer, args); }, warn: (...args: unknown[]) => { - originalConsole.warn(...(args as any[])); + originalConsole.warn(...args); append(errorBuffer, args); }, error: (...args: unknown[]) => { - originalConsole.error(...(args as any[])); + originalConsole.error(...args); append(errorBuffer, args); }, }; @@ -120,7 +122,6 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { let exitCode = 0; try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval const runner = new Function( // Inject the libraries/objects we want to expose to the cell code "d3", @@ -140,6 +141,11 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { const stdoutText = logBuffer.join("\n"); const stderrText = errorBuffer.join("\n"); + const renderedText = container.textContent?.trim() ?? ""; + const renderedElements = container.querySelectorAll("*").length; + const hasRenderedContent = renderedElements > 0 || renderedText.length > 0; + const hasTerminalOutput = + stdoutText.trim().length > 0 || stderrText.trim().length > 0; const updatedCell = create(parser_pb.CellSchema, cell); updatedCell.outputs = createCellOutputs( @@ -161,6 +167,7 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { setStdout(stdoutText); setStderr(stderrText); + setHasRenderableOutput(hasRenderedContent || hasTerminalOutput); onExitCode(exitCode); }, [cell, notebookData, onExitCode, onPid]); @@ -186,7 +193,11 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { }, [stderr, stdout]); return ( -
+
Observable Output{" "} {lastRunId @@ -194,11 +205,12 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => { : ""}
- {hasStdIO ? ( + {hasStdIO && (
{stdout.trim().length > 0 && (
@@ -221,10 +233,6 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => {
)}
- ) : ( -
- Use d3 (or aisre.render) to draw into the panel above. -
)}
); diff --git a/app/src/components/Actions/outputVisibility.test.ts b/app/src/components/Actions/outputVisibility.test.ts new file mode 100644 index 0000000..ab17bb8 --- /dev/null +++ b/app/src/components/Actions/outputVisibility.test.ts @@ -0,0 +1,43 @@ +import { create } from "@bufbuild/protobuf"; +import { describe, expect, it } from "vitest"; + +import { MimeType, parser_pb } from "../../runme/client"; +import { hasVisibleCellOutput } from "./outputVisibility"; + +describe("hasVisibleCellOutput", () => { + it("returns false for empty output arrays", () => { + expect(hasVisibleCellOutput([])).toBe(false); + }); + + it("returns false for empty terminal placeholder items", () => { + const outputs = [ + create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: MimeType.StatefulRunmeTerminal, + type: "Buffer", + data: new Uint8Array(), + }), + ], + }), + ]; + + expect(hasVisibleCellOutput(outputs)).toBe(false); + }); + + it("returns true when stdout has bytes", () => { + const outputs = [ + create(parser_pb.CellOutputSchema, { + items: [ + create(parser_pb.CellOutputItemSchema, { + mime: MimeType.VSCodeNotebookStdOut, + type: "Buffer", + data: new TextEncoder().encode("hello"), + }), + ], + }), + ]; + + expect(hasVisibleCellOutput(outputs)).toBe(true); + }); +}); diff --git a/app/src/components/Actions/outputVisibility.ts b/app/src/components/Actions/outputVisibility.ts new file mode 100644 index 0000000..d15a392 --- /dev/null +++ b/app/src/components/Actions/outputVisibility.ts @@ -0,0 +1,30 @@ +import { MimeType, parser_pb } from "../../runme/client"; + +const OUTPUT_VISIBILITY_IGNORE_MIMES = new Set([ + MimeType.StatefulRunmeTerminal, +]); + +/** + * Determine whether a cell has user-visible output bytes. + * + * Cells may contain a placeholder terminal output item that allocates renderer + * state with an empty buffer. That placeholder should not force the output + * panel to render for idle cells. + */ +export function hasVisibleCellOutput( + outputs: parser_pb.CellOutput[] | undefined, +): boolean { + if (!outputs || outputs.length === 0) { + return false; + } + + return outputs.some((output) => + (output.items ?? []).some((item) => { + if (!item || OUTPUT_VISIBILITY_IGNORE_MIMES.has(item.mime || "")) { + return false; + } + const data = item.data as Uint8Array | undefined; + return Boolean(data && data.length > 0); + }), + ); +} diff --git a/app/test/browser/test-scenario-hello-world.ts b/app/test/browser/test-scenario-hello-world.ts index 2e85074..eb8fc03 100644 --- a/app/test/browser/test-scenario-hello-world.ts +++ b/app/test/browser/test-scenario-hello-world.ts @@ -92,6 +92,8 @@ for (const file of [ "scenario-hello-world-04-before-open.txt", "scenario-hello-world-04b-after-expand.txt", "scenario-hello-world-05-opened-notebook.txt", + "scenario-hello-world-05-before-run.png", + "scenario-hello-world-05-before-run-output-check.txt", "scenario-hello-world-06-after-run.txt", "scenario-hello-world-06-after-run.png", ]) { @@ -131,7 +133,7 @@ const seedResult = run( refId: 'cell_hello_world', kind: 2, languageId: 'bash', - value: 'echo \\\"hello world\\\"', + value: 'echo hello world', metadata: { runner: 'local' }, outputs: [] } @@ -211,7 +213,31 @@ if (notebookRef) { snapshot = run("agent-browser snapshot -i").stdout; writeArtifact("scenario-hello-world-05-opened-notebook.txt", snapshot); -let runRef = firstRef(snapshot, /Run cell/i) ?? firstRef(snapshot, /\bRun\b/i); +run(`agent-browser screenshot ${join(OUTPUT_DIR, "scenario-hello-world-05-before-run.png")}`); + +const beforeRunOutputCheck = run( + `agent-browser eval "(() => { + const outputItems = document.querySelectorAll('[data-testid="cell-output-item"]'); + const consoles = document.querySelectorAll('[data-testid="cell-console"]'); + const webContainerShells = document.querySelectorAll('[id^="webcontainer-output-shell-"]:not(.hidden)'); + return JSON.stringify({ + outputItemCount: outputItems.length, + consoleCount: consoles.length, + visibleWebContainerOutputCount: webContainerShells.length, + }); + })()"`, +).stdout; +writeArtifact("scenario-hello-world-05-before-run-output-check.txt", beforeRunOutputCheck); + +if (beforeRunOutputCheck.includes('"outputItemCount":0') && + beforeRunOutputCheck.includes('"consoleCount":0') && + beforeRunOutputCheck.includes('"visibleWebContainerOutputCount":0')) { + pass("Cell shows no output panel before execution"); +} else { + fail(`Unexpected visible output before execution: ${beforeRunOutputCheck.trim()}`); +} + +const runRef = firstRef(snapshot, /Run cell/i) ?? firstRef(snapshot, /\bRun\b/i); if (runRef) { run(`agent-browser click ${runRef}`); run("agent-browser wait 3500"); diff --git a/docs-dev/cujs/hello-world-local-notebook.md b/docs-dev/cujs/hello-world-local-notebook.md index 2ad8f32..d5c4578 100644 --- a/docs-dev/cujs/hello-world-local-notebook.md +++ b/docs-dev/cujs/hello-world-local-notebook.md @@ -17,8 +17,9 @@ cell end-to-end. 3. Create a new local notebook with one bash cell: `echo "hello world"`. 4. Use AppConsole to add a local runner endpoint. 5. Open the local notebook. -6. Execute the bash cell. -7. Verify execution output contains `hello world`. +6. Verify the notebook shows no output for the cell before it is executed. +7. Execute the bash cell. +8. Verify execution output contains `hello world`. ## Machine-verifiable acceptance criteria @@ -26,5 +27,6 @@ cell end-to-end. - [ ] `aisreRunners.getDefault()` reports runner `local` after setting default. - [ ] The workspace tree shows notebook `scenario-hello-world.runme.md`. - [ ] Clicking the notebook opens a tab with the notebook name. +- [ ] Before running, the first cell shows no rendered output panel. - [ ] Running the first cell completes without a blocking runner error. - [ ] The rendered output area includes `hello world`.