From da90538c443d3f40873b53af191700c2c9683e24 Mon Sep 17 00:00:00 2001 From: Hamel Husain Date: Mon, 16 Feb 2026 14:02:49 -0800 Subject: [PATCH] feat(app): add cell execution state indicators and flush partial stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual feedback during cell execution (issue #38) and fix prompts without trailing newlines being buffered indefinitely (issue #99). Execution state machine: idle → pending → running → success/error with colored left-border indicators and bracket notation in the gutter. Timeout-based flush (150ms) for partial stdout lines in both the React component and data layer, with IOPub classification deferral preserved. Co-Authored-By: Claude Opus 4.6 --- app/src/components/Actions/Actions.test.tsx | 210 +++++++++++++++++- app/src/components/Actions/Actions.tsx | 121 +++++++--- .../components/Actions/CellConsole.test.tsx | 153 ++++++++++++- app/src/components/Actions/CellConsole.tsx | 20 ++ app/src/index.css | 25 ++- app/src/lib/notebookData.ts | 38 +++- app/test/browser/test-notebook-ui.sh | 21 ++ .../notebooks/stdin-prompt-test.runme.md | 31 +++ 8 files changed, 582 insertions(+), 37 deletions(-) create mode 100644 app/test/fixtures/notebooks/stdin-prompt-test.runme.md diff --git a/app/src/components/Actions/Actions.test.tsx b/app/src/components/Actions/Actions.test.tsx index 28b4042..c4f166f 100644 --- a/app/src/components/Actions/Actions.test.tsx +++ b/app/src/components/Actions/Actions.test.tsx @@ -4,7 +4,7 @@ import { render, screen, act, fireEvent } from "@testing-library/react"; import { create } from "@bufbuild/protobuf"; import React from "react"; -import { parser_pb } from "../../runme/client"; +import { parser_pb, RunmeMetadataKey } from "../../runme/client"; import type { CellData } from "../../lib/notebookData"; import { Action } from "./Actions"; @@ -60,6 +60,47 @@ vi.mock("@runmedev/renderers", () => ({ vi.mock("../../contexts/CellContext", () => ({})); +// Fake streams for testing PID/exitCode callbacks through CellConsole subscriptions. +class FakeStreams { + private pidCbs = new Set<(pid: number) => void>(); + private exitCbs = new Set<(code: number) => void>(); + private stdoutCbs = new Set<(data: Uint8Array) => void>(); + private stderrCbs = new Set<(data: Uint8Array) => void>(); + + stdout = { + subscribe: (cb: (data: Uint8Array) => void) => { + this.stdoutCbs.add(cb); + return { unsubscribe: () => this.stdoutCbs.delete(cb) }; + }, + }; + stderr = { + subscribe: (cb: (data: Uint8Array) => void) => { + this.stderrCbs.add(cb); + return { unsubscribe: () => this.stderrCbs.delete(cb) }; + }, + }; + pid = { + subscribe: (cb: (pid: number) => void) => { + this.pidCbs.add(cb); + return { unsubscribe: () => this.pidCbs.delete(cb) }; + }, + }; + exitCode = { + subscribe: (cb: (code: number) => void) => { + this.exitCbs.add(cb); + return { unsubscribe: () => this.exitCbs.delete(cb) }; + }, + }; + + emitPid(pid: number) { this.pidCbs.forEach((cb) => cb(pid)); } + emitExitCode(code: number) { this.exitCbs.forEach((cb) => cb(code)); } + emitStdout(data: Uint8Array) { this.stdoutCbs.forEach((cb) => cb(data)); } + + setCallback() {} + sendExecuteRequest() {} + close() {} +} + // Minimal stub CellData to drive runID changes. class StubCellData { snapshot: parser_pb.Cell; @@ -103,13 +144,16 @@ class StubCellData { this.runListeners.forEach((l) => l(id)); } + fakeStreams: FakeStreams | null = null; + getStreams() { - return null; + return this.fakeStreams; } addBefore() {} addAfter() {} remove() {} run() {} + setRunner() {} } describe("Action component", () => { @@ -189,4 +233,166 @@ describe("Action component", () => { expect(updatedCell.kind).toBe(parser_pb.CellKind.MARKUP); expect(updatedCell.languageId).toBe("markdown"); }); + + it("shows idle bracket [ ] before first run", () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-idle", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: {}, + value: "echo hello", + }); + const stub = new StubCellData(cell); + + render(); + + const bracket = screen.getByTestId("cell-bracket"); + expect(bracket.textContent).toBe("[ ]"); + }); + + it("shows pending state immediately on run click", async () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-pend", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: {}, + value: "echo hello", + }); + const stub = new StubCellData(cell); + stub.fakeStreams = new FakeStreams(); + + render(); + + const cellCard = document.getElementById("cell-card-cell-pend")!; + expect(cellCard.getAttribute("data-exec-state")).toBe("idle"); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Run code")); + }); + + expect(cellCard.getAttribute("data-exec-state")).toBe("pending"); + const bracket = screen.getByTestId("cell-bracket"); + expect(bracket.textContent).toBe("[*]"); + }); + + it("transitions to running state on PID", async () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-run", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: {}, + value: "echo hello", + }); + const stub = new StubCellData(cell); + const streams = new FakeStreams(); + stub.fakeStreams = streams; + + render(); + + // Wait for CellConsole's useEffect to subscribe to streams + await act(async () => { await Promise.resolve(); }); + + // Click run to enter pending state + await act(async () => { + fireEvent.click(screen.getByLabelText("Run code")); + }); + + const cellCard = document.getElementById("cell-card-cell-run")!; + expect(cellCard.getAttribute("data-exec-state")).toBe("pending"); + + // Emit PID to transition to running + await act(async () => { + streams.emitPid(42); + }); + + expect(cellCard.getAttribute("data-exec-state")).toBe("running"); + }); + + it("transitions to success state on exitCode 0", async () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-ok", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: {}, + value: "echo hello", + }); + cell.metadata[RunmeMetadataKey.Sequence] = "1"; + const stub = new StubCellData(cell); + const streams = new FakeStreams(); + stub.fakeStreams = streams; + + render(); + await act(async () => { await Promise.resolve(); }); + + // Run → PID → exit 0 + await act(async () => { fireEvent.click(screen.getByLabelText("Run code")); }); + await act(async () => { streams.emitPid(42); }); + await act(async () => { streams.emitExitCode(0); }); + + const cellCard = document.getElementById("cell-card-cell-ok")!; + expect(cellCard.getAttribute("data-exec-state")).toBe("success"); + + const bracket = screen.getByTestId("cell-bracket"); + expect(bracket.textContent).toBe("[1]"); + }); + + it("transitions to error state on non-zero exitCode", async () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-err", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: {}, + value: "exit 1", + }); + const stub = new StubCellData(cell); + const streams = new FakeStreams(); + stub.fakeStreams = streams; + + render(); + await act(async () => { await Promise.resolve(); }); + + await act(async () => { fireEvent.click(screen.getByLabelText("Run code")); }); + await act(async () => { streams.emitPid(42); }); + await act(async () => { streams.emitExitCode(1); }); + + const cellCard = document.getElementById("cell-card-cell-err")!; + expect(cellCard.getAttribute("data-exec-state")).toBe("error"); + + const bracket = screen.getByTestId("cell-bracket"); + expect(bracket.textContent).toBe("[!]"); + }); + + it("resets to pending on re-run after success", async () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-rerun", + kind: parser_pb.CellKind.CODE, + languageId: "bash", + outputs: [], + metadata: {}, + value: "echo hello", + }); + const stub = new StubCellData(cell); + const streams = new FakeStreams(); + stub.fakeStreams = streams; + + render(); + await act(async () => { await Promise.resolve(); }); + + // First run → success + await act(async () => { fireEvent.click(screen.getByLabelText("Run code")); }); + await act(async () => { streams.emitPid(42); }); + await act(async () => { streams.emitExitCode(0); }); + + const cellCard = document.getElementById("cell-card-cell-rerun")!; + expect(cellCard.getAttribute("data-exec-state")).toBe("success"); + + // Re-run → should go back to pending + await act(async () => { fireEvent.click(screen.getByLabelText("Run code")); }); + expect(cellCard.getAttribute("data-exec-state")).toBe("pending"); + }); }); diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index d541944..fcb5cb6 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -27,9 +27,11 @@ import Editor from "./Editor"; import MarkdownCell from "./MarkdownCell"; import { IOPUB_INCOMPLETE_METADATA_KEY } from "../../lib/ipykernel"; import { + ErrorIcon, PlayIcon, PlusIcon, SpinnerIcon, + SuccessIcon, TrashIcon, } from "./icons"; //import { useRun } from "../../lib/useRun.js"; @@ -71,31 +73,59 @@ TabPanel.displayName = "TabPanel"; // them. Inactive tabs are taken out of flow via absolute positioning and hidden // visibility so they don't visually overlap yet retain their state. +export type CellExecState = "idle" | "pending" | "running" | "success" | "error"; + /** Compact icon-only run button that sits in the cell toolbar. - * Shows a spinner while running, otherwise always shows the play icon. */ + * Shows a spinner while pending/running, success/error icons after completion. */ function RunActionButton({ - pid, + execState, + exitCode, onClick, }: { - pid: number | null; + execState: CellExecState; + exitCode: number | null; onClick: () => void; }) { - const isRunning = pid !== null; + const [faded, setFaded] = useState(false); + + useEffect(() => { + if (execState === "success") { + setFaded(false); + const timer = setTimeout(() => setFaded(true), 2000); + return () => clearTimeout(timer); + } + setFaded(false); + }, [execState]); + + let icon: React.ReactNode; + if (execState === "pending" || execState === "running") { + icon = ( +
+ +
+ ); + } else if (execState === "success" && !faded) { + icon = ; + } else if (execState === "error" && exitCode !== null) { + icon = ; + } else { + icon = ; + } return ( ); } @@ -313,15 +343,38 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo const [markdownEditRequest, setMarkdownEditRequest] = useState(0); const [pid, setPid] = useState(null); const [exitCode, setExitCode] = useState(null); + const [pending, setPending] = useState(false); - // When an exit code arrives, clear the pid so the spinner stops. + // When an exit code arrives, clear the pid and pending so the state resolves. const handleExitCode = useCallback((code: number | null) => { setExitCode(code); + setPending(false); if (code !== null) { setPid(null); } }, []); + // When a PID arrives, clear pending (transitions to "running"). + const handlePid = useCallback((newPid: number | null) => { + setPid(newPid); + if (newPid !== null) { + setPending(false); + } + }, []); + + const execState: CellExecState = useMemo(() => { + if (exitCode !== null) { + return exitCode === 0 ? "success" : "error"; + } + if (pid !== null) { + return "running"; + } + if (pending) { + return "pending"; + } + return "idle"; + }, [exitCode, pid, pending]); + useEffect(() => { if (!contextMenu) { return; @@ -366,6 +419,9 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo }, [contextMenu]); const runCode = useCallback(() => { + setPending(true); + setPid(null); + setExitCode(null); cellData.run(); }, [cellData]); @@ -384,15 +440,12 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo }, [cellData]); const sequenceLabel = useMemo(() => { - if (!cell) { - return " "; - } + if (execState === "error") return "!"; + if (execState === "pending" || execState === "running") return "*"; + if (!cell) return " "; const seq = Number(cell.metadata[RunmeMetadataKey.Sequence]); - if (!seq) { - return " "; - } - return seq.toString(); - }, [cell, pid, exitCode]); + return seq ? seq.toString() : " "; + }, [cell, execState]); const selectedLanguage = useMemo(() => { if (!cell) { @@ -443,7 +496,7 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo ); @@ -488,7 +541,7 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo ); @@ -661,6 +714,7 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo
{/* Code editor section — overflow-hidden keeps border-radius clipping on the editor */}
@@ -720,14 +774,23 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo ))} - {sequenceLabel.trim() && ( - - [{sequenceLabel}] - - )} + + [{sequenceLabel}] +
- +