diff --git a/app/package.json b/app/package.json index 8d9ee9f..7bf4696 100644 --- a/app/package.json +++ b/app/package.json @@ -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": { diff --git a/app/src/App.tsx b/app/src/App.tsx index ca12304..69fab1f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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(); @@ -131,6 +132,16 @@ function App({ branding, initialState = {} }: AppProps) { return ( <> + {appBranding.name} diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index 3348c98..0891d8c 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, useSyncExternalStore, } from "react"; @@ -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 & { "data-state"?: "active" | "inactive"; @@ -338,6 +340,7 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo } | null>(null); const [pid, setPid] = useState(null); const [exitCode, setExitCode] = useState(null); + const connectionTimeoutRef = useRef | null>(null); useEffect(() => { if (!contextMenu) { @@ -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.", { + id: "no-runner", + duration: 5000, + }); + 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) => { event.preventDefault(); @@ -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(); } }} > diff --git a/app/src/lib/notebookData.test.ts b/app/src/lib/notebookData.test.ts index e2b4857..684e51b 100644 --- a/app/src/lib/notebookData.test.ts +++ b/app/src/lib/notebookData.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { Subject } from "rxjs"; import { create } from "@bufbuild/protobuf"; import { beforeAll, describe, expect, it, vi } from "vitest"; @@ -216,3 +217,113 @@ describe("NotebookData.getActiveStream", () => { expect(model.getActiveStream(cell.refId)).toBeUndefined(); }); }); + +describe("NotebookData markup cell methods", () => { + function makeModel(cells: InstanceType[]) { + const notebook = create(parser_pb.NotebookSchema, { cells }); + return new NotebookData({ + notebook, + uri: "nb://test", + name: "test", + notebookStore: null, + loaded: true, + }); + } + + function getCells(model: InstanceType) { + 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"); + }); + }); +}); diff --git a/app/src/lib/notebookData.ts b/app/src/lib/notebookData.ts index d60687f..75aa5f7 100644 --- a/app/src/lib/notebookData.ts +++ b/app/src/lib/notebookData.ts @@ -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) { @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fe0343..4349f5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) ulid: specifier: ^2.4.0 version: 2.4.0 @@ -4520,6 +4523,12 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -9634,6 +9643,11 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {}