Skip to content

Commit c4c3ad7

Browse files
authored
Notebook controls with special LiveStore events for bulk actions (#600)
Depends on #657 Added controls (new LiveStore events behind a feature flag): - Run all cells - Restart and run all cells (placeholder for now, shows toast) - Stop all cells - Clear all outputs - Delete all cells Commits: * WIP * WIP first pass dropdown menu * Move hide AI cells button * Add toasts for stopping cells * Implement hide AI cells * Show badge if cells running or AI cells are hidden * Use standard function for generating a queue id * Fix stop all cells * Do filtering on sqlite side * Show message if user adds AI cell if AI cells are hidden * Add warning if hiding AI cells * Use a single event for running all cells * Use single event for clearing all outputs and stopping execution * Update queue id generation to resist collisions better * Rename stop to cancel * Cancel all without arguments * Use past tense * Clear all with almost no arguments * Update toast * Don't return ops * Fix getting cells * Remove unused * Move runnableCellsWithIndices$ * Remove hide AI cells NB control * Change menu titles * Fix type error * Show live running status * Remove badge * merge: main to nb-controls-2 * Put bulk nb controls behind a feature flag * Remove unused * Move new queries into one place * Add extra checks * Undo schema change * Remove unimportant changes
1 parent 67a6444 commit c4c3ad7

File tree

5 files changed

+230
-5
lines changed

5 files changed

+230
-5
lines changed

src/components/notebook/cell/ExecutableCell.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { MaybeCellOutputs } from "@/components/outputs/MaybeCellOutputs.js";
4343
import { useToolApprovals } from "@/hooks/useToolApprovals.js";
4444
import { AiToolApprovalOutput } from "../../outputs/shared-with-iframe/AiToolApprovalOutput.js";
4545
import { cn } from "@/lib/utils.js";
46+
import { generateQueueId } from "@/util/queue-id.js";
4647
import { useTrpc } from "@/components/TrpcProvider.js";
4748
import { cycleCellType } from "@/util/cycle-cell-type.js";
4849
import { useFeatureFlag } from "@/contexts/FeatureFlagContext.js";
@@ -246,15 +247,12 @@ export const ExecutableCell: React.FC<ExecutableCellProps> = ({
246247
);
247248

248249
// Generate unique queue ID
249-
const queueId = `exec-${Date.now()}-${Math.random()
250-
.toString(36)
251-
.slice(2)}`;
252250
const executionCount = (cell.executionCount || 0) + 1;
253251

254252
// Add to execution queue - runtimes will pick this up
255253
store.commit(
256254
events.executionRequested({
257-
queueId,
255+
queueId: generateQueueId(),
258256
cellId: cell.id,
259257
executionCount,
260258
requestedBy: userId,

src/components/notebooks/notebook/NotebookControls.tsx

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,157 @@ import { toast } from "sonner";
1616
import { useCreateNotebookAndNavigate } from "../dashboard/helpers";
1717
import type { NotebookProcessed } from "../types";
1818

19+
import { useAuthenticatedUser } from "@/auth/index.js";
20+
import { useDebug } from "@/components/debug/debug-mode";
21+
import { Spinner } from "@/components/ui/Spinner";
22+
import { useFeatureFlag } from "@/contexts/FeatureFlagContext";
23+
import { useRuntimeHealth } from "@/hooks/useRuntimeHealth";
24+
import { runnableCellsWithIndices$, runningCells$ } from "@/queries";
25+
import { generateQueueId } from "@/util/queue-id";
26+
import { useQuery, useStore } from "@livestore/react";
27+
import { CellData, events, queries } from "@runtimed/schema";
28+
import { Eraser, Play, Square, Undo2 } from "lucide-react";
29+
import { useCallback } from "react";
30+
1931
export function NotebookControls({
2032
notebook,
2133
}: {
2234
notebook: NotebookProcessed;
2335
}) {
36+
const allowBulkNotebookControls = useFeatureFlag("bulk-notebook-controls");
37+
const { store } = useStore();
38+
const userId = useAuthenticatedUser();
39+
const debug = useDebug();
40+
41+
const cellQueue = useQuery(runningCells$);
42+
43+
const handleCancelAll = useCallback(() => {
44+
if (!allowBulkNotebookControls) {
45+
toast.info("Bulk notebook controls are not enabled");
46+
return;
47+
}
48+
if (cellQueue.length === 0) {
49+
toast.info("No cells to stop");
50+
return;
51+
}
52+
store.commit(events.allExecutionsCancelled());
53+
toast.info("Cancelled all executions");
54+
}, [store, cellQueue, allowBulkNotebookControls]);
55+
56+
const handleClearAllOutputs = useCallback(() => {
57+
if (!allowBulkNotebookControls) {
58+
toast.info("Bulk notebook controls are not enabled");
59+
return;
60+
}
61+
store.commit(events.allOutputsCleared({ clearedBy: userId }));
62+
}, [store, userId, allowBulkNotebookControls]);
63+
2464
return (
25-
<div className="flex items-center gap-1">
65+
<div className="flex items-center gap-2">
66+
{allowBulkNotebookControls && (
67+
<ActiveBulkNotebookActions
68+
cellQueue={cellQueue}
69+
onCancelAll={handleCancelAll}
70+
/>
71+
)}
2672
<DropdownMenu>
2773
<DropdownMenuTrigger asChild>
2874
<Button variant="ghost" size="sm" className="relative">
2975
<MoreHorizontal className="h-4 w-4" />
3076
</Button>
3177
</DropdownMenuTrigger>
3278
<DropdownMenuContent align="end">
79+
{allowBulkNotebookControls && (
80+
<BulkNotebookActions
81+
cellQueue={cellQueue}
82+
onCancelAll={handleCancelAll}
83+
onClearAllOutputs={handleClearAllOutputs}
84+
/>
85+
)}
3386
<CreateNotebookAction />
3487
<DuplicateAction notebook={notebook} />
3588
<DropdownMenuSeparator />
89+
{debug.enabled && <DeleteAllCellsAction />}
3690
<DeleteAction notebook={notebook} />
3791
</DropdownMenuContent>
3892
</DropdownMenu>
3993
</div>
4094
);
4195
}
4296

97+
function ActiveBulkNotebookActions({
98+
cellQueue,
99+
onCancelAll,
100+
}: {
101+
cellQueue: readonly CellData[];
102+
onCancelAll: () => void;
103+
}) {
104+
if (cellQueue.length === 0) {
105+
return null;
106+
}
107+
108+
return (
109+
<>
110+
<span className="flex items-center gap-1">
111+
<Spinner size="md" />
112+
<span className="text-muted-foreground text-sm">
113+
Running {cellQueue.length} cells
114+
</span>
115+
</span>
116+
<Button variant="outline" size="sm" onClick={onCancelAll}>
117+
<Square />
118+
Stop All
119+
</Button>
120+
</>
121+
);
122+
}
123+
124+
function BulkNotebookActions({
125+
cellQueue,
126+
onCancelAll,
127+
onClearAllOutputs,
128+
}: {
129+
cellQueue: readonly CellData[];
130+
onCancelAll: () => void;
131+
onClearAllOutputs: () => void;
132+
}) {
133+
const { runAllCells } = useRunAllCells();
134+
const { restartAndRunAllCells } = useRestartAndRunAllCells();
135+
136+
return (
137+
<>
138+
{cellQueue.length > 0 ? (
139+
<DropdownMenuItem onClick={onCancelAll}>
140+
<Square />
141+
Stop All
142+
</DropdownMenuItem>
143+
) : (
144+
<>
145+
<DropdownMenuItem onClick={runAllCells}>
146+
<Play />
147+
Run All Code Cells
148+
</DropdownMenuItem>
149+
<DropdownMenuItem onClick={restartAndRunAllCells}>
150+
<span className="relative">
151+
<Play className="h-4 w-4" />
152+
<Undo2
153+
className="absolute bottom-0 left-0 size-3 -translate-x-[3px] translate-y-[3px] rounded-full bg-white p-[1px] text-gray-700"
154+
strokeWidth={3}
155+
/>
156+
</span>
157+
Restart and Run All Code Cells
158+
</DropdownMenuItem>
159+
</>
160+
)}
161+
<DropdownMenuItem onClick={onClearAllOutputs}>
162+
<Eraser />
163+
Clear All Outputs
164+
</DropdownMenuItem>
165+
<DropdownMenuSeparator />
166+
</>
167+
);
168+
}
169+
43170
function CreateNotebookAction() {
44171
const createNotebookAndNavigate = useCreateNotebookAndNavigate();
45172

@@ -126,3 +253,74 @@ function DeleteAction({ notebook }: { notebook: NotebookProcessed }) {
126253
</DropdownMenuItem>
127254
);
128255
}
256+
257+
function DeleteAllCellsAction() {
258+
const { deleteAllCells } = useDeleteAllCells();
259+
const { confirm } = useConfirm();
260+
261+
const handleDeleteAllCells = async () => {
262+
confirm({
263+
title: "Delete All Cells",
264+
description: "Are you sure you want to delete all cells?",
265+
onConfirm: deleteAllCells,
266+
actionButtonText: "Delete All Cells",
267+
});
268+
};
269+
return (
270+
<DropdownMenuItem variant="destructive" onSelect={handleDeleteAllCells}>
271+
<Trash2 />
272+
DEBUG: Delete All Cells
273+
</DropdownMenuItem>
274+
);
275+
}
276+
277+
function useDeleteAllCells() {
278+
const { store } = useStore();
279+
const cells = useQuery(queries.cellsWithIndices$);
280+
const userId = useAuthenticatedUser();
281+
282+
const deleteAllCells = useCallback(() => {
283+
cells.forEach((cell) => {
284+
store.commit(events.cellDeleted({ id: cell.id, actorId: userId }));
285+
});
286+
}, [cells, store, userId]);
287+
288+
return { deleteAllCells };
289+
}
290+
291+
function useRunAllCells() {
292+
const { store } = useStore();
293+
const cells = useQuery(runnableCellsWithIndices$);
294+
const userId = useAuthenticatedUser();
295+
296+
const runAllCells = useCallback(() => {
297+
store.commit(
298+
events.multipleExecutionRequested({
299+
requestedBy: userId,
300+
cellsInfo: [
301+
...cells.map((cell) => ({
302+
id: cell.id,
303+
executionCount: (cell.executionCount || 0) + 1,
304+
queueId: generateQueueId(),
305+
})),
306+
],
307+
})
308+
);
309+
}, [store, userId, cells]);
310+
311+
return { runAllCells };
312+
}
313+
314+
function useRestartAndRunAllCells() {
315+
const { hasActiveRuntime } = useRuntimeHealth();
316+
317+
const restartAndRunAllCells = useCallback(() => {
318+
if (hasActiveRuntime) {
319+
toast.info("Restart your runtime manually and run all cells");
320+
} else {
321+
toast.error("No active runtime found");
322+
}
323+
}, [hasActiveRuntime]);
324+
325+
return { restartAndRunAllCells };
326+
}

src/contexts/FeatureFlagContext.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface FeatureFlags {
66
"test-flag": boolean;
77
"ipynb-export": boolean;
88
"file-upload": boolean;
9+
/** Whether to enable the notebook controls */
10+
"bulk-notebook-controls": boolean;
911
/** 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. */
1012
"show-ai-capabilities": boolean;
1113
"user-saved-prompt": boolean;
@@ -16,7 +18,9 @@ const DEFAULT_FLAGS: FeatureFlags = {
1618
"test-flag": false,
1719
"ipynb-export": false,
1820
"file-upload": false,
21+
"bulk-notebook-controls": false,
1922
"show-ai-capabilities": false,
23+
2024
"user-saved-prompt": false,
2125
} as const;
2226

src/queries/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@ export const availableFiles$ = queryDb(
2424
tables.files.select().where({ deletedAt: null }),
2525
{ label: "files.availableFiles" }
2626
);
27+
28+
export const runnableCellsWithIndices$ = queryDb(
29+
tables.cells
30+
.select("id", "fractionalIndex", "cellType", "executionCount")
31+
.where({ cellType: { op: "IN", value: ["code", "sql"] } })
32+
.orderBy("fractionalIndex", "asc"),
33+
{ label: "cells.withIndices.runnable" }
34+
);
35+
36+
export const runningCells$ = queryDb(
37+
tables.cells
38+
.select()
39+
.where({ executionState: { op: "IN", value: ["running", "queued"] } })
40+
.orderBy("fractionalIndex", "asc"),
41+
{ label: "cells.runningCells" }
42+
);

src/util/queue-id.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Generates a unique queue ID for an execution request
3+
*/
4+
export function generateQueueId() {
5+
// Date should prevent most of the collisions, but not as reliable when making bulk requests
6+
// 6 char length + alphabet of 36 chars = 6K IDs to have 1% chance of collision
7+
// See: https://zelark.github.io/nano-id-cc/
8+
return `exec-${Date.now()}-${Math.random().toString(36).slice(6)}`;
9+
}

0 commit comments

Comments
 (0)