Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/components/Actions/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement> & {
"data-state"?: "active" | "inactive";
Expand Down Expand Up @@ -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;
}

Expand Down
57 changes: 57 additions & 0 deletions app/src/components/Actions/WebContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WebContainer cell={cell} onExitCode={vi.fn()} onPid={vi.fn()} />);

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);
});
});
32 changes: 20 additions & 12 deletions app/src/components/Actions/WebContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const toPrintable = (value: unknown) => {
}
try {
return JSON.stringify(value);
} catch (err) {
} catch {
return String(value);
}
};
Expand All @@ -46,6 +46,7 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => {
const [stdout, setStdout] = useState<string>("");
const [stderr, setStderr] = useState<string>("");
const [lastRunId, setLastRunId] = useState<number>(0);
const [hasRenderableOutput, setHasRenderableOutput] = useState<boolean>(false);
const activeRunIdRef = useRef<number | null>(null);

const runCode = useCallback(async () => {
Expand All @@ -60,6 +61,7 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => {
setLastRunId(runId);
setStdout("");
setStderr("");
setHasRenderableOutput(false);
container.innerHTML = "";
onPid(null);

Expand All @@ -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);
},
};
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -161,6 +167,7 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => {

setStdout(stdoutText);
setStderr(stderrText);
setHasRenderableOutput(hasRenderedContent || hasTerminalOutput);
onExitCode(exitCode);
}, [cell, notebookData, onExitCode, onPid]);

Expand All @@ -186,19 +193,24 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => {
}, [stderr, stdout]);

return (
<div className="mt-2 rounded-md border border-nb-cell-border bg-white p-2 text-xs text-nb-text">
<div
id={`webcontainer-output-shell-${cell.refId}`}
className={hasRenderableOutput ? "mt-2 rounded-md border border-nb-cell-border bg-white p-2 text-xs text-nb-text" : "hidden"}
aria-hidden={!hasRenderableOutput}
>
<div className="mb-1 font-mono text-[10px] uppercase tracking-wide text-nb-text-faint">
Observable Output{" "}
{lastRunId
? `(last run: ${new Date(lastRunId).toLocaleTimeString()})`
: ""}
</div>
<div
id={`webcontainer-output-content-${cell.refId}`}
ref={containerRef}
className="mb-2 min-h-[240px] w-full overflow-auto rounded border border-dashed border-nb-cell-border bg-nb-surface-2"
/>

{hasStdIO ? (
{hasStdIO && (
<div className="space-y-2 font-mono">
{stdout.trim().length > 0 && (
<div>
Expand All @@ -221,10 +233,6 @@ const WebContainer = ({ cell, onExitCode, onPid }: ObservableOutputProps) => {
</div>
)}
</div>
) : (
<div className="font-mono text-[11px] italic text-nb-text-faint">
Use d3 (or aisre.render) to draw into the panel above.
</div>
)}
</div>
);
Expand Down
43 changes: 43 additions & 0 deletions app/src/components/Actions/outputVisibility.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions app/src/components/Actions/outputVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { MimeType, parser_pb } from "../../runme/client";

const OUTPUT_VISIBILITY_IGNORE_MIMES = new Set<string>([
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);
}),
);
}
30 changes: 28 additions & 2 deletions app/test/browser/test-scenario-hello-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]) {
Expand Down Expand Up @@ -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: []
}
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 4 additions & 2 deletions docs-dev/cujs/hello-world-local-notebook.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ 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

- [ ] The App Console accepts `aisreRunners.update("local", "http://localhost:9977")`.
- [ ] `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`.