diff --git a/src/components/notebook/cell/ExecutableCell.tsx b/src/components/notebook/cell/ExecutableCell.tsx index 161e5dcb..d67a90a2 100644 --- a/src/components/notebook/cell/ExecutableCell.tsx +++ b/src/components/notebook/cell/ExecutableCell.tsx @@ -43,6 +43,7 @@ import { MaybeCellOutputs } from "@/components/outputs/MaybeCellOutputs.js"; import { useToolApprovals } from "@/hooks/useToolApprovals.js"; import { AiToolApprovalOutput } from "../../outputs/shared-with-iframe/AiToolApprovalOutput.js"; import { cn } from "@/lib/utils.js"; +import { generateQueueId } from "@/util/queue-id.js"; import { useTrpc } from "@/components/TrpcProvider.js"; import { cycleCellType } from "@/util/cycle-cell-type.js"; import { useFeatureFlag } from "@/contexts/FeatureFlagContext.js"; @@ -246,15 +247,12 @@ export const ExecutableCell: React.FC = ({ ); // Generate unique queue ID - const queueId = `exec-${Date.now()}-${Math.random() - .toString(36) - .slice(2)}`; const executionCount = (cell.executionCount || 0) + 1; // Add to execution queue - runtimes will pick this up store.commit( events.executionRequested({ - queueId, + queueId: generateQueueId(), cellId: cell.id, executionCount, requestedBy: userId, diff --git a/src/components/notebooks/notebook/NotebookControls.tsx b/src/components/notebooks/notebook/NotebookControls.tsx index 23e770d5..06c96d91 100644 --- a/src/components/notebooks/notebook/NotebookControls.tsx +++ b/src/components/notebooks/notebook/NotebookControls.tsx @@ -16,13 +16,59 @@ import { toast } from "sonner"; import { useCreateNotebookAndNavigate } from "../dashboard/helpers"; import type { NotebookProcessed } from "../types"; +import { useAuthenticatedUser } from "@/auth/index.js"; +import { useDebug } from "@/components/debug/debug-mode"; +import { Spinner } from "@/components/ui/Spinner"; +import { useFeatureFlag } from "@/contexts/FeatureFlagContext"; +import { useRuntimeHealth } from "@/hooks/useRuntimeHealth"; +import { runnableCellsWithIndices$, runningCells$ } from "@/queries"; +import { generateQueueId } from "@/util/queue-id"; +import { useQuery, useStore } from "@livestore/react"; +import { CellData, events, queries } from "@runtimed/schema"; +import { Eraser, Play, Square, Undo2 } from "lucide-react"; +import { useCallback } from "react"; + export function NotebookControls({ notebook, }: { notebook: NotebookProcessed; }) { + const allowBulkNotebookControls = useFeatureFlag("bulk-notebook-controls"); + const { store } = useStore(); + const userId = useAuthenticatedUser(); + const debug = useDebug(); + + const cellQueue = useQuery(runningCells$); + + const handleCancelAll = useCallback(() => { + if (!allowBulkNotebookControls) { + toast.info("Bulk notebook controls are not enabled"); + return; + } + if (cellQueue.length === 0) { + toast.info("No cells to stop"); + return; + } + store.commit(events.allExecutionsCancelled()); + toast.info("Cancelled all executions"); + }, [store, cellQueue, allowBulkNotebookControls]); + + const handleClearAllOutputs = useCallback(() => { + if (!allowBulkNotebookControls) { + toast.info("Bulk notebook controls are not enabled"); + return; + } + store.commit(events.allOutputsCleared({ clearedBy: userId })); + }, [store, userId, allowBulkNotebookControls]); + return ( -
+
+ {allowBulkNotebookControls && ( + + )} + {allowBulkNotebookControls && ( + + )} + {debug.enabled && } @@ -40,6 +94,79 @@ export function NotebookControls({ ); } +function ActiveBulkNotebookActions({ + cellQueue, + onCancelAll, +}: { + cellQueue: readonly CellData[]; + onCancelAll: () => void; +}) { + if (cellQueue.length === 0) { + return null; + } + + return ( + <> + + + + Running {cellQueue.length} cells + + + + + ); +} + +function BulkNotebookActions({ + cellQueue, + onCancelAll, + onClearAllOutputs, +}: { + cellQueue: readonly CellData[]; + onCancelAll: () => void; + onClearAllOutputs: () => void; +}) { + const { runAllCells } = useRunAllCells(); + const { restartAndRunAllCells } = useRestartAndRunAllCells(); + + return ( + <> + {cellQueue.length > 0 ? ( + + + Stop All + + ) : ( + <> + + + Run All Code Cells + + + + + + + Restart and Run All Code Cells + + + )} + + + Clear All Outputs + + + + ); +} + function CreateNotebookAction() { const createNotebookAndNavigate = useCreateNotebookAndNavigate(); @@ -126,3 +253,74 @@ function DeleteAction({ notebook }: { notebook: NotebookProcessed }) { ); } + +function DeleteAllCellsAction() { + const { deleteAllCells } = useDeleteAllCells(); + const { confirm } = useConfirm(); + + const handleDeleteAllCells = async () => { + confirm({ + title: "Delete All Cells", + description: "Are you sure you want to delete all cells?", + onConfirm: deleteAllCells, + actionButtonText: "Delete All Cells", + }); + }; + return ( + + + DEBUG: Delete All Cells + + ); +} + +function useDeleteAllCells() { + const { store } = useStore(); + const cells = useQuery(queries.cellsWithIndices$); + const userId = useAuthenticatedUser(); + + const deleteAllCells = useCallback(() => { + cells.forEach((cell) => { + store.commit(events.cellDeleted({ id: cell.id, actorId: userId })); + }); + }, [cells, store, userId]); + + return { deleteAllCells }; +} + +function useRunAllCells() { + const { store } = useStore(); + const cells = useQuery(runnableCellsWithIndices$); + const userId = useAuthenticatedUser(); + + const runAllCells = useCallback(() => { + store.commit( + events.multipleExecutionRequested({ + requestedBy: userId, + cellsInfo: [ + ...cells.map((cell) => ({ + id: cell.id, + executionCount: (cell.executionCount || 0) + 1, + queueId: generateQueueId(), + })), + ], + }) + ); + }, [store, userId, cells]); + + return { runAllCells }; +} + +function useRestartAndRunAllCells() { + const { hasActiveRuntime } = useRuntimeHealth(); + + const restartAndRunAllCells = useCallback(() => { + if (hasActiveRuntime) { + toast.info("Restart your runtime manually and run all cells"); + } else { + toast.error("No active runtime found"); + } + }, [hasActiveRuntime]); + + return { restartAndRunAllCells }; +} diff --git a/src/contexts/FeatureFlagContext.tsx b/src/contexts/FeatureFlagContext.tsx index 0cd4b413..281f7d95 100644 --- a/src/contexts/FeatureFlagContext.tsx +++ b/src/contexts/FeatureFlagContext.tsx @@ -6,6 +6,8 @@ export interface FeatureFlags { "test-flag": boolean; "ipynb-export": boolean; "file-upload": boolean; + /** Whether to enable the notebook controls */ + "bulk-notebook-controls": boolean; /** Show AI capabilities in the AI cell dropdown. We'd enable this by default if we support vision or allow choosing models that don't have tool support. */ "show-ai-capabilities": boolean; "user-saved-prompt": boolean; @@ -16,7 +18,9 @@ const DEFAULT_FLAGS: FeatureFlags = { "test-flag": false, "ipynb-export": false, "file-upload": false, + "bulk-notebook-controls": false, "show-ai-capabilities": false, + "user-saved-prompt": false, } as const; diff --git a/src/queries/index.ts b/src/queries/index.ts index 476b42e3..fee43c03 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -24,3 +24,19 @@ export const availableFiles$ = queryDb( tables.files.select().where({ deletedAt: null }), { label: "files.availableFiles" } ); + +export const runnableCellsWithIndices$ = queryDb( + tables.cells + .select("id", "fractionalIndex", "cellType", "executionCount") + .where({ cellType: { op: "IN", value: ["code", "sql"] } }) + .orderBy("fractionalIndex", "asc"), + { label: "cells.withIndices.runnable" } +); + +export const runningCells$ = queryDb( + tables.cells + .select() + .where({ executionState: { op: "IN", value: ["running", "queued"] } }) + .orderBy("fractionalIndex", "asc"), + { label: "cells.runningCells" } +); diff --git a/src/util/queue-id.ts b/src/util/queue-id.ts new file mode 100644 index 00000000..762421cb --- /dev/null +++ b/src/util/queue-id.ts @@ -0,0 +1,9 @@ +/** + * Generates a unique queue ID for an execution request + */ +export function generateQueueId() { + // Date should prevent most of the collisions, but not as reliable when making bulk requests + // 6 char length + alphabet of 36 chars = 6K IDs to have 1% chance of collision + // See: https://zelark.github.io/nano-id-cc/ + return `exec-${Date.now()}-${Math.random().toString(36).slice(6)}`; +}