Skip to content
Closed
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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"remark-gfm": "^3.0.1",
"rxjs": "^7.8.2",
"ulid": "^2.4.0",
"sonner": "^2.0.7",
"uuid": "^11.1.0"
},
"devDependencies": {
Expand Down
11 changes: 11 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { Interceptor } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SidePanelProvider } from "./contexts/SidePanelContext";
import { appState } from "./lib/runtime/AppState";
import { Toaster } from "sonner";

const queryClient = new QueryClient();

Expand Down Expand Up @@ -131,6 +132,16 @@ function App({ branding, initialState = {} }: AppProps) {
return (
<>
<Theme accentColor="gray" scaling="110%" radius="small">
<Toaster
position="bottom-right"
toastOptions={{
style: {
fontFamily: "var(--default-font-family, system-ui, sans-serif)",
fontSize: "13px",
borderRadius: "var(--radius-2, 4px)",
},
}}
/>
<Helmet>
<title>{appBranding.name}</title>
<meta name="description" content={`${appBranding.name}`} />
Expand Down
64 changes: 48 additions & 16 deletions app/src/components/Actions/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
Expand Down Expand Up @@ -38,6 +39,7 @@ import { useCurrentDoc } from "../../contexts/CurrentDocContext";
import { useRunners } from "../../contexts/RunnersContext";
import { DEFAULT_RUNNER_PLACEHOLDER } from "../../lib/runtime/runnersManager";
import React from "react";
import { toast } from "sonner";

type TabPanelProps = React.HTMLAttributes<HTMLDivElement> & {
"data-state"?: "active" | "inactive";
Expand Down Expand Up @@ -338,6 +340,7 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo
} | null>(null);
const [pid, setPid] = useState<number | null>(null);
const [exitCode, setExitCode] = useState<number | null>(null);
const connectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (!contextMenu) {
Expand Down Expand Up @@ -383,9 +386,50 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo
}, [contextMenu]);

const runCode = useCallback(() => {
cellData.run();
// Clear any pending connection timeout from a previous run.
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}

const runID = cellData.run();

if (!runID) {
toast.error("No runner configured. Check your runner settings in the console.", {
Comment on lines +395 to +398

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use actual run ID before showing "No runner" toast

cellData.run() is typed to return void (NotebookData/CellData still define run(): void), so runID here is always undefined and if (!runID) always executes. In practice, every Run click will show the "No runner configured" toast and return early from this handler even when a runner is configured and execution was started, which makes the new availability logic incorrect for normal runs.

Useful? React with 👍 / 👎.

id: "no-runner",
duration: 5000,
});
Comment on lines +395 to +401
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CellData.run() returns void (see app/src/lib/notebookData.ts), but this code treats it as returning a runID. As written, runID will always be undefined, so the "No runner configured" toast will always show and the backend-timeout toast logic will never run. Update runCode to not rely on a return value from cellData.run() (e.g., change CellData.run() to return the generated runID, or trigger the toast based on an explicit success/failure signal).

Suggested change
const runID = cellData.run();
if (!runID) {
toast.error("No runner configured. Check your runner settings in the console.", {
id: "no-runner",
duration: 5000,
});
try {
cellData.run();
} catch (error) {
toast.error(
"No runner configured. Check your runner settings in the console.",
{
id: "no-runner",
duration: 5000,
},
);

Copilot uses AI. Check for mistakes.
return;
}

// If the backend is down the WebSocket will silently fail to connect.
// Set a timeout: if no PID arrives within 5 seconds, warn the user.
connectionTimeoutRef.current = setTimeout(() => {
toast.error(
"Unable to connect to the Runme service. Make sure the backend is running.",
{ id: "runner-unavailable", duration: 8000 },
);
connectionTimeoutRef.current = null;
}, 5000);
}, [cellData]);

// Clear the connection timeout once a PID arrives (backend responded).
useEffect(() => {
if (pid !== null && connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
}, [pid]);

// Cleanup timeout on unmount.
useEffect(() => {
return () => {
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
}
};
}, []);

const handleContextMenu = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -819,24 +863,12 @@ function NotebookTabContent({ docUri }: { docUri: string }) {
if (!data) {
return;
}
// Default to inserting a code cell when notebook is empty.
// Insert a markup (markdown) cell at the top of the notebook.
const firstCell = cellDatas[0]?.snapshot;
if (firstCell) {
data.addCodeCellBefore(firstCell.refId);
data.addMarkupCellBefore(firstCell.refId);
} else {
const newCell = data.addCodeCellAfter("");
if (!newCell) {
// Fallback: create and persist a new code cell at the end.
const cell = create(parser_pb.CellSchema, {
metadata: {},
refId: `code_${crypto.randomUUID().replace(/-/g, "")}`,
languageId: "bash",
role: parser_pb.CellRole.USER,
kind: parser_pb.CellKind.CODE,
value: "",
});
data.updateCell(cell);
}
data.appendMarkupCell();
}
Comment on lines +866 to 872
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is described as adding a toast when the Runme backend is unavailable, but this hunk changes the global "Add cell" button behavior to insert a markup (markdown) cell (and adds new NotebookData markup cell APIs/tests). If this is intentional, the PR description should be updated; otherwise, consider moving these notebook/markup-cell changes into a separate PR to keep scope aligned and reduce review risk.

Copilot uses AI. Check for mistakes.
}}
>
Expand Down
111 changes: 111 additions & 0 deletions app/src/lib/notebookData.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @vitest-environment node
import { Subject } from "rxjs";
import { create } from "@bufbuild/protobuf";
import { beforeAll, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -216,3 +217,113 @@ describe("NotebookData.getActiveStream", () => {
expect(model.getActiveStream(cell.refId)).toBeUndefined();
});
});

describe("NotebookData markup cell methods", () => {
function makeModel(cells: InstanceType<typeof parser_pb.Cell>[]) {
const notebook = create(parser_pb.NotebookSchema, { cells });
return new NotebookData({
notebook,
uri: "nb://test",
name: "test",
notebookStore: null,
loaded: true,
});
}

function getCells(model: InstanceType<typeof NotebookData>) {
return model.getSnapshot().notebook.cells;
}

describe("appendMarkupCell", () => {
it("appends a markup cell to an empty notebook", () => {
const model = makeModel([]);
const cell = model.appendMarkupCell();
const cells = getCells(model);

expect(cell).toBeTruthy();
expect(cell.kind).toBe(parser_pb.CellKind.MARKUP);
expect(cell.languageId).toBe("markdown");
expect(cell.value).toBe("");
expect(cell.refId).toMatch(/^markup_/);
expect(cells.length).toBe(1);
expect(cells[0].refId).toBe(cell.refId);
});

it("appends a markup cell after existing cells", () => {
const existing = create(parser_pb.CellSchema, {
refId: "code_existing",
kind: parser_pb.CellKind.CODE,
languageId: "js",
value: "console.log(1)",
metadata: {},
outputs: [],
});
const model = makeModel([existing]);
const cell = model.appendMarkupCell();
const cells = getCells(model);

expect(cells.length).toBe(2);
expect(cells[0].refId).toBe("code_existing");
expect(cells[1].refId).toBe(cell.refId);
expect(cell.kind).toBe(parser_pb.CellKind.MARKUP);
});
});

describe("addMarkupCellBefore", () => {
it("inserts a markup cell before a target cell", () => {
const existing = create(parser_pb.CellSchema, {
refId: "code_first",
kind: parser_pb.CellKind.CODE,
languageId: "bash",
value: "echo hi",
metadata: {},
outputs: [],
});
const model = makeModel([existing]);
const cell = model.addMarkupCellBefore("code_first");
const cells = getCells(model);

expect(cell).toBeTruthy();
expect(cell!.kind).toBe(parser_pb.CellKind.MARKUP);
expect(cell!.languageId).toBe("markdown");
expect(cell!.refId).toMatch(/^markup_/);
expect(cells.length).toBe(2);
expect(cells[0].refId).toBe(cell!.refId);
expect(cells[1].refId).toBe("code_first");
});

it("returns null for an invalid refId", () => {
const model = makeModel([]);
const result = model.addMarkupCellBefore("nonexistent");
expect(result).toBeNull();
expect(getCells(model).length).toBe(0);
});

it("inserts before the correct cell in a multi-cell notebook", () => {
const cell1 = create(parser_pb.CellSchema, {
refId: "cell_1",
kind: parser_pb.CellKind.CODE,
languageId: "js",
value: "1",
metadata: {},
outputs: [],
});
const cell2 = create(parser_pb.CellSchema, {
refId: "cell_2",
kind: parser_pb.CellKind.CODE,
languageId: "js",
value: "2",
metadata: {},
outputs: [],
});
const model = makeModel([cell1, cell2]);
const inserted = model.addMarkupCellBefore("cell_2");
const cells = getCells(model);

expect(cells.length).toBe(3);
expect(cells[0].refId).toBe("cell_1");
expect(cells[1].refId).toBe(inserted!.refId);
expect(cells[2].refId).toBe("cell_2");
});
});
});
36 changes: 36 additions & 0 deletions app/src/lib/notebookData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,30 @@ export class NotebookData {
return cell;
}

addMarkupCellBefore(targetRefId: string): parser_pb.Cell | null {
const idx = this.refToIndex.get(targetRefId);
if (idx === undefined) {
return null;
}
const cell = this.createMarkupCell();
this.notebook.cells.splice(idx, 0, cell);
this.rebuildIndex();
this.snapshotCache = this.buildSnapshot();
this.emit();
void this.persist();
return cell;
}

appendMarkupCell(): parser_pb.Cell {
const cell = this.createMarkupCell();
this.notebook.cells.push(cell);
this.rebuildIndex();
this.snapshotCache = this.buildSnapshot();
this.emit();
void this.persist();
return cell;
}

removeCell(refId: string): void {
const idx = this.refToIndex.get(refId);
if (idx === undefined) {
Expand Down Expand Up @@ -577,6 +601,18 @@ export class NotebookData {
return this.createAndBindStreams({ cell, runID, sequence, runner });
}

private createMarkupCell(): parser_pb.Cell {
const refID = `markup_${crypto.randomUUID().replace(/-/g, "")}`;
return create(parser_pb.CellSchema, {
metadata: {},
refId: refID,
languageId: "markdown",
role: parser_pb.CellRole.USER,
kind: parser_pb.CellKind.MARKUP,
value: "",
});
}

private createCodeCell(languageId?: string | null): parser_pb.Cell {
const refID = `code_${crypto.randomUUID().replace(/-/g, "")}`;
const normalizedLanguage = languageId?.trim().toLowerCase();
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading