@@ -1080,7 +1146,10 @@ export function Action({
className="cell-card"
>
{/* Code editor section — overflow-hidden keeps border-radius clipping on the editor */}
-
+
{
const updated = create(parser_pb.CellSchema, cell);
updated.value = v;
@@ -1270,7 +1340,17 @@ export function Action({
);
}
-function NotebookTabContent({ docUri }: { docUri: string }) {
+function NotebookTabContent({
+ docUri,
+ activeCell,
+ isWindowFocused,
+ onCellFocus,
+}: {
+ docUri: string;
+ activeCell: NotebookActiveCellState | null;
+ isWindowFocused: boolean;
+ onCellFocus: (docUri: string, state: NotebookActiveCellState) => void;
+}) {
const { getNotebookData, useNotebookSnapshot } = useNotebookContext();
const notebookSnapshot = useNotebookSnapshot(docUri);
const cellDatas = useMemo(() => {
@@ -1329,6 +1409,10 @@ function NotebookTabContent({ docUri }: { docUri: string }) {
cellData={cellData}
docUri={docUri}
isFirst={index === 0}
+ isActiveCell={activeCell?.refId === refId}
+ activeFocusRole={activeCell?.focusRole ?? "editor"}
+ isWindowFocused={isWindowFocused}
+ onFocusStateChange={(state) => onCellFocus(docUri, state)}
/>
);
})}
@@ -1363,6 +1447,15 @@ export default function Actions() {
Boolean(driveLinkSnapshot.lastErrorMessage);
const [mountedTabs, setMountedTabs] = useState>(() => new Set());
const [selectedTabUri, setSelectedTabUri] = useState(null);
+ const [activeCellsByDoc, setActiveCellsByDoc] = useState(
+ () => loadNotebookActiveCellMap(),
+ );
+ const [isWindowFocused, setIsWindowFocused] = useState(() => {
+ if (typeof document === "undefined") {
+ return false;
+ }
+ return document.visibilityState === "visible" && document.hasFocus();
+ });
// Empty-state hint visibility is stored locally so the hint panel can be
// revealed on demand without cluttering the default view.
const [showConsoleHints, setShowConsoleHints] = useState(false);
@@ -1410,6 +1503,27 @@ export default function Actions() {
currentDocUri ??
(statusTabVisible ? DRIVE_LINK_STATUS_TAB_URI : openNotebooks[0]?.uri ?? "");
+ const handleCellFocus = useCallback(
+ (docUri: string, state: NotebookActiveCellState) => {
+ setActiveCellsByDoc((prev) => {
+ const current = prev[docUri];
+ if (
+ current?.refId === state.refId &&
+ current.focusRole === state.focusRole
+ ) {
+ return prev;
+ }
+ const next = {
+ ...prev,
+ [docUri]: state,
+ };
+ persistNotebookActiveCellMap(next);
+ return next;
+ });
+ },
+ [],
+ );
+
// Ensure the active tab is tracked as mounted on first render/whenever it changes.
useEffect(() => {
if (!currentDocUri) {
@@ -1439,6 +1553,28 @@ export default function Actions() {
}
}, [currentDocUri, openNotebooks, selectedTabUri, statusTabVisible]);
+ useEffect(() => {
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return;
+ }
+
+ const syncWindowFocus = () => {
+ setIsWindowFocused(
+ document.visibilityState === "visible" && document.hasFocus(),
+ );
+ };
+
+ syncWindowFocus();
+ window.addEventListener("focus", syncWindowFocus);
+ window.addEventListener("blur", syncWindowFocus);
+ document.addEventListener("visibilitychange", syncWindowFocus);
+ return () => {
+ window.removeEventListener("focus", syncWindowFocus);
+ window.removeEventListener("blur", syncWindowFocus);
+ document.removeEventListener("visibilitychange", syncWindowFocus);
+ };
+ }, []);
+
// Keep the active tab discoverable when the tab rail overflows horizontally.
// We track each rendered tab node and ask the browser to reveal the selected
// one so keyboard/mouse tab changes do not leave the active notebook clipped.
@@ -1884,7 +2020,14 @@ export default function Actions() {
asChild
>
-
+
))}
diff --git a/app/src/components/Actions/Editor.tsx b/app/src/components/Actions/Editor.tsx
index bed4da5..a312be6 100644
--- a/app/src/components/Actions/Editor.tsx
+++ b/app/src/components/Actions/Editor.tsx
@@ -17,6 +17,8 @@ const Editor = memo(
readOnly = false,
ariaLabel,
autoFocusWhenEmpty = true,
+ shouldFocus = false,
+ onFocus,
onChange,
onEnter,
onMount,
@@ -29,6 +31,8 @@ const Editor = memo(
readOnly?: boolean;
ariaLabel?: string;
autoFocusWhenEmpty?: boolean;
+ shouldFocus?: boolean;
+ onFocus?: () => void;
onChange: (value: string) => void;
onEnter: () => void;
onMount?: (editor: any, monaco: any) => void;
@@ -46,6 +50,8 @@ const Editor = memo(
// don't render an empty scroll track.
const [isClamped, setIsClamped] = useState(false);
const contentSizeListener = useRef<{ dispose: () => void } | null>(null);
+ const focusListener = useRef<{ dispose: () => void } | null>(null);
+ const previousShouldFocusRef = useRef(false);
// Keep the ref updated with the latest onEnter
useEffect(() => {
@@ -146,6 +152,9 @@ const Editor = memo(
contentSizeListener.current = editor.onDidContentSizeChange(() => {
adjustHeight();
});
+ focusListener.current = editor.onDidFocusEditorText(() => {
+ onFocus?.();
+ });
onMount?.(editor, monaco);
};
@@ -160,10 +169,21 @@ const Editor = memo(
editorRef.current.layout?.({ width, height });
}, [width, height]);
+ useEffect(() => {
+ const wasFocused = previousShouldFocusRef.current;
+ previousShouldFocusRef.current = shouldFocus;
+ if (!shouldFocus || wasFocused || readOnly || !editorRef.current) {
+ return;
+ }
+ editorRef.current.focus?.();
+ }, [readOnly, shouldFocus]);
+
useEffect(() => {
return () => {
contentSizeListener.current?.dispose?.();
contentSizeListener.current = null;
+ focusListener.current?.dispose?.();
+ focusListener.current = null;
};
}, []);
@@ -220,7 +240,8 @@ const Editor = memo(
prevProps.language === nextProps.language &&
prevProps.readOnly === nextProps.readOnly &&
prevProps.ariaLabel === nextProps.ariaLabel &&
- prevProps.autoFocusWhenEmpty === nextProps.autoFocusWhenEmpty
+ prevProps.autoFocusWhenEmpty === nextProps.autoFocusWhenEmpty &&
+ prevProps.shouldFocus === nextProps.shouldFocus
);
},
);
diff --git a/app/src/components/Actions/MarkdownCell.test.tsx b/app/src/components/Actions/MarkdownCell.test.tsx
new file mode 100644
index 0000000..0e1df9a
--- /dev/null
+++ b/app/src/components/Actions/MarkdownCell.test.tsx
@@ -0,0 +1,260 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from "vitest";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { clone, create } from "@bufbuild/protobuf";
+import React from "react";
+
+import { parser_pb } from "../../runme/client";
+import type { CellData } from "../../lib/notebookData";
+import MarkdownCell from "./MarkdownCell";
+
+vi.mock("./Editor", () => ({
+ default: ({
+ id,
+ value,
+ onChange,
+ shouldFocus = false,
+ }: {
+ id: string;
+ value: string;
+ onChange: (value: string) => void;
+ shouldFocus?: boolean;
+ }) => {
+ const ref = React.useRef(null);
+ const previousShouldFocusRef = React.useRef(false);
+
+ React.useEffect(() => {
+ const wasFocused = previousShouldFocusRef.current;
+ previousShouldFocusRef.current = shouldFocus;
+ if (!shouldFocus || wasFocused) {
+ return;
+ }
+ ref.current?.focus();
+ }, [shouldFocus]);
+
+ return (
+
@@ -363,6 +433,7 @@ const MarkdownCell = memo(
onBlur={handleBlur}
onKeyDown={handleEditorKeyDown}
data-testid="markdown-editor"
+ data-cell-focus-role="editor"
>
onFocusRoleChange?.("editor")}
onChange={handleEditorChange}
onEnter={handleRun}
/>
@@ -399,8 +476,16 @@ const MarkdownCell = memo(
);
},
(prevProps, nextProps) => {
- // Skip re-render if the cellData reference hasn't changed
- return prevProps.cellData === nextProps.cellData;
+ return (
+ prevProps.cellData === nextProps.cellData &&
+ prevProps.selectedLanguage === nextProps.selectedLanguage &&
+ prevProps.languageSelectId === nextProps.languageSelectId &&
+ prevProps.forceEditRequest === nextProps.forceEditRequest &&
+ prevProps.isActiveCell === nextProps.isActiveCell &&
+ prevProps.activeFocusRole === nextProps.activeFocusRole &&
+ prevProps.isWindowFocused === nextProps.isWindowFocused &&
+ prevProps.onFocusRoleChange === nextProps.onFocusRoleChange
+ );
}
);
diff --git a/app/src/lib/notebookActiveCellState.test.ts b/app/src/lib/notebookActiveCellState.test.ts
new file mode 100644
index 0000000..01482e2
--- /dev/null
+++ b/app/src/lib/notebookActiveCellState.test.ts
@@ -0,0 +1,77 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ createNotebookActiveCellState,
+ loadNotebookActiveCellMap,
+ persistNotebookActiveCellMap,
+} from "./notebookActiveCellState";
+
+const STORAGE_KEY = "runme/notebook-active-cells";
+
+describe("notebookActiveCellState", () => {
+ afterEach(() => {
+ localStorage.removeItem(STORAGE_KEY);
+ });
+
+ it("normalizes persisted notebook active-cell state", () => {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ " local://file/demo.json ": {
+ refId: " cell-a ",
+ focusRole: "rendered",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ },
+ "": {
+ refId: "ignored",
+ focusRole: "editor",
+ updatedAt: "",
+ },
+ "local://file/bad.json": {
+ refId: "",
+ focusRole: "rendered",
+ updatedAt: "",
+ },
+ }),
+ );
+
+ expect(loadNotebookActiveCellMap()).toEqual({
+ "local://file/demo.json": {
+ refId: "cell-a",
+ focusRole: "rendered",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ },
+ });
+ });
+
+ it("persists only valid entries", () => {
+ persistNotebookActiveCellMap({
+ " local://file/demo.json ": {
+ refId: " cell-a ",
+ focusRole: "editor",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ },
+ "": {
+ refId: "",
+ focusRole: "rendered",
+ updatedAt: "",
+ },
+ });
+
+ expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")).toEqual({
+ "local://file/demo.json": {
+ refId: "cell-a",
+ focusRole: "editor",
+ updatedAt: "2026-05-14T00:00:00.000Z",
+ },
+ });
+ });
+
+ it("creates timestamped active-cell entries", () => {
+ const snapshot = createNotebookActiveCellState("cell-a", "rendered");
+ expect(snapshot?.refId).toBe("cell-a");
+ expect(snapshot?.focusRole).toBe("rendered");
+ expect(snapshot?.updatedAt).toMatch(/^20\d\d-/);
+ });
+});
diff --git a/app/src/lib/notebookActiveCellState.ts b/app/src/lib/notebookActiveCellState.ts
new file mode 100644
index 0000000..b95f28d
--- /dev/null
+++ b/app/src/lib/notebookActiveCellState.ts
@@ -0,0 +1,106 @@
+export type CellFocusRole = "editor" | "rendered";
+
+export type NotebookActiveCellState = {
+ refId: string;
+ focusRole: CellFocusRole;
+ updatedAt: string;
+};
+
+export type NotebookActiveCellMap = Record;
+
+const STORAGE_KEY = "runme/notebook-active-cells";
+
+function normalizeString(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function normalizeFocusRole(value: unknown): CellFocusRole {
+ return value === "rendered" ? "rendered" : "editor";
+}
+
+function normalizeEntry(value: unknown): NotebookActiveCellState | null {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+ const candidate = value as Partial;
+ const refId = normalizeString(candidate.refId);
+ if (!refId) {
+ return null;
+ }
+ return {
+ refId,
+ focusRole: normalizeFocusRole(candidate.focusRole),
+ updatedAt: normalizeString(candidate.updatedAt),
+ };
+}
+
+export function loadNotebookActiveCellMap(): NotebookActiveCellMap {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return {};
+ }
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return {};
+ }
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return {};
+ }
+ const next: NotebookActiveCellMap = {};
+ for (const [docUri, entry] of Object.entries(parsed)) {
+ const normalizedDocUri = normalizeString(docUri);
+ if (!normalizedDocUri) {
+ continue;
+ }
+ const normalizedEntry = normalizeEntry(entry);
+ if (!normalizedEntry) {
+ continue;
+ }
+ next[normalizedDocUri] = normalizedEntry;
+ }
+ return next;
+ } catch {
+ return {};
+ }
+}
+
+export function persistNotebookActiveCellMap(
+ snapshot: NotebookActiveCellMap,
+): void {
+ if (typeof window === "undefined" || !window.localStorage) {
+ return;
+ }
+ try {
+ const next: NotebookActiveCellMap = {};
+ for (const [docUri, entry] of Object.entries(snapshot)) {
+ const normalizedDocUri = normalizeString(docUri);
+ if (!normalizedDocUri) {
+ continue;
+ }
+ const normalizedEntry = normalizeEntry(entry);
+ if (!normalizedEntry) {
+ continue;
+ }
+ next[normalizedDocUri] = normalizedEntry;
+ }
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ // Ignore localStorage write failures; focus restore should degrade safely.
+ }
+}
+
+export function createNotebookActiveCellState(
+ refId: string,
+ focusRole: CellFocusRole,
+): NotebookActiveCellState | null {
+ const normalizedRefId = normalizeString(refId);
+ if (!normalizedRefId) {
+ return null;
+ }
+ return {
+ refId: normalizedRefId,
+ focusRole: normalizeFocusRole(focusRole),
+ updatedAt: new Date().toISOString(),
+ };
+}
diff --git a/app/test/browser/run-cuj-scenarios.ts b/app/test/browser/run-cuj-scenarios.ts
index 2952d68..0ee3a0a 100644
--- a/app/test/browser/run-cuj-scenarios.ts
+++ b/app/test/browser/run-cuj-scenarios.ts
@@ -87,6 +87,7 @@ const SCENARIO_DRIVERS = [
join(SCRIPT_DIR, "test-scenario-ai.ts"),
join(SCRIPT_DIR, "test-scenario-ai-codex.ts"),
join(SCRIPT_DIR, "test-scenario-chatkit-thread-persistence.ts"),
+ join(SCRIPT_DIR, "test-scenario-notebook-focus-persistence.ts"),
];
type BackendCommandConfig = {
diff --git a/app/test/browser/test-scenario-notebook-focus-persistence.ts b/app/test/browser/test-scenario-notebook-focus-persistence.ts
new file mode 100644
index 0000000..06e2afc
--- /dev/null
+++ b/app/test/browser/test-scenario-notebook-focus-persistence.ts
@@ -0,0 +1,338 @@
+import { spawnSync } from "node:child_process";
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const FRONTEND_URL = process.env.CUJ_FRONTEND_URL ?? "http://localhost:5173";
+const NOTEBOOK_NAME = "scenario-notebook-focus.json";
+const NOTEBOOK_URI = `local://file/${NOTEBOOK_NAME}`;
+const MARKDOWN_CELL_ID = "md_focus_cell";
+const STORAGE_KEY = "runme/notebook-active-cells";
+const LOCAL_DB_NAME = "runme-local-notebooks";
+
+const CURRENT_FILE_DIR = dirname(fileURLToPath(import.meta.url));
+const SCRIPT_DIR =
+ CURRENT_FILE_DIR.endsWith("/.generated") || CURRENT_FILE_DIR.endsWith("\\.generated")
+ ? dirname(CURRENT_FILE_DIR)
+ : CURRENT_FILE_DIR;
+const OUTPUT_DIR = join(SCRIPT_DIR, "test-output");
+const MOVIE_PATH = join(OUTPUT_DIR, "scenario-notebook-focus-persistence-walkthrough.webm");
+const AGENT_BROWSER_SESSION = process.env.AGENT_BROWSER_SESSION?.trim() ?? "";
+const AGENT_BROWSER_PROFILE = process.env.AGENT_BROWSER_PROFILE?.trim() ?? "";
+const AGENT_BROWSER_HEADED = (process.env.AGENT_BROWSER_HEADED ?? "false")
+ .trim()
+ .toLowerCase() === "true";
+const AGENT_BROWSER_KEEP_OPEN = (process.env.AGENT_BROWSER_KEEP_OPEN ?? "false")
+ .trim()
+ .toLowerCase() === "true";
+
+let passCount = 0;
+let failCount = 0;
+let totalCount = 0;
+
+function run(
+ command: string,
+ options?: { timeoutMs?: number; throwOnAgentBrowserTimeout?: boolean },
+): { status: number; stdout: string; stderr: string } {
+ const effectiveCommand = withAgentBrowserOptions(command);
+ const timeoutMs = options?.timeoutMs ?? Number(process.env.CUJ_SCENARIO_CMD_TIMEOUT_MS ?? "15000");
+ const result = spawnSync(effectiveCommand, {
+ shell: true,
+ encoding: "utf-8",
+ timeout: timeoutMs,
+ killSignal: "SIGKILL",
+ });
+ const errorCode =
+ typeof result.error === "object" && result.error !== null && "code" in result.error
+ ? String((result.error as { code?: string }).code ?? "")
+ : "";
+ const timedOut = errorCode === "ETIMEDOUT";
+ const timeoutHint = timedOut
+ ? `\n[scenario-timeout] command timed out after ${timeoutMs}ms: ${effectiveCommand}\n`
+ : "";
+ const shouldThrowOnTimeout = options?.throwOnAgentBrowserTimeout ?? true;
+ if (
+ timedOut &&
+ shouldThrowOnTimeout &&
+ effectiveCommand.trim().startsWith("agent-browser ")
+ ) {
+ throw new Error(timeoutHint.trim());
+ }
+ return {
+ status: result.status ?? (timedOut ? 124 : 1),
+ stdout: result.stdout ?? "",
+ stderr: `${result.stderr ?? ""}${timeoutHint}`,
+ };
+}
+
+function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
+}
+
+function withAgentBrowserOptions(command: string): string {
+ const trimmed = command.trimStart();
+ if (!trimmed.startsWith("agent-browser ")) {
+ return command;
+ }
+ const leadingWhitespace = command.slice(0, command.length - trimmed.length);
+ const subcommand = trimmed.slice("agent-browser ".length);
+ const args: string[] = [];
+ if (AGENT_BROWSER_SESSION) {
+ args.push("--session", shellQuote(AGENT_BROWSER_SESSION));
+ }
+ if (AGENT_BROWSER_PROFILE) {
+ args.push("--profile", shellQuote(AGENT_BROWSER_PROFILE));
+ }
+ if (AGENT_BROWSER_HEADED) {
+ args.push("--headed");
+ }
+ const prefix = ["agent-browser", ...args].join(" ");
+ return `${leadingWhitespace}${prefix} ${subcommand}`;
+}
+
+function runOrThrow(command: string): string {
+ const result = run(command);
+ if (result.status !== 0) {
+ throw new Error(`Command failed: ${command}\n${result.stderr}`);
+ }
+ return result.stdout;
+}
+
+function pass(message: string): void {
+ totalCount += 1;
+ passCount += 1;
+ console.log(`[PASS] ${message}`);
+}
+
+function fail(message: string): void {
+ totalCount += 1;
+ failCount += 1;
+ console.log(`[FAIL] ${message}`);
+}
+
+function writeArtifact(name: string, content: string): void {
+ writeFileSync(join(OUTPUT_DIR, name), content, "utf-8");
+}
+
+function escapeDoubleQuotes(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
+
+function decodeAgentBrowserEvalOutput(output: string): string {
+ const trimmed = output.trim();
+ if (!trimmed) {
+ return "";
+ }
+ try {
+ const parsed = JSON.parse(trimmed);
+ return typeof parsed === "string" ? parsed : JSON.stringify(parsed);
+ } catch {
+ return trimmed;
+ }
+}
+
+mkdirSync(OUTPUT_DIR, { recursive: true });
+rmSync(MOVIE_PATH, { force: true });
+for (const file of [
+ "scenario-notebook-focus-persistence-01-seed.json",
+ "scenario-notebook-focus-persistence-02-storage-after-edit.json",
+ "scenario-notebook-focus-persistence-03-after-reload-snapshot.txt",
+ "scenario-notebook-focus-persistence-04-after-reload-state.json",
+ "scenario-notebook-focus-persistence-05-after-reload.png",
+]) {
+ rmSync(join(OUTPUT_DIR, file), { force: true });
+}
+
+if (run("command -v agent-browser").status !== 0) {
+ console.error("ERROR: agent-browser is required on PATH");
+ process.exit(2);
+}
+
+if (run(`curl -sf ${FRONTEND_URL}`).status !== 0) {
+ console.error(`ERROR: frontend is not running at ${FRONTEND_URL}`);
+ process.exit(1);
+}
+
+let startedRecording = false;
+
+try {
+ runOrThrow(`agent-browser open ${FRONTEND_URL}`);
+ runOrThrow(`agent-browser record restart ${MOVIE_PATH}`);
+ startedRecording = true;
+ run("agent-browser wait 2200");
+
+ const seedResult = decodeAgentBrowserEvalOutput(
+ run(
+ `agent-browser eval "(async () => {
+ const openLocalDb = () => new Promise((resolve, reject) => {
+ const request = indexedDB.open('${LOCAL_DB_NAME}');
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains('files')) {
+ db.createObjectStore('files', { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains('folders')) {
+ db.createObjectStore('folders', { keyPath: 'id' });
+ }
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+ const runTransaction = (db, storeName, mode, callback) => new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, mode);
+ const store = tx.objectStore(storeName);
+ const request = callback(store);
+ tx.oncomplete = () => resolve(request?.result);
+ tx.onerror = () => reject(tx.error ?? request?.error);
+ tx.onabort = () => reject(tx.error ?? request?.error);
+ });
+ const notebook = {
+ metadata: {},
+ cells: [
+ {
+ refId: '${MARKDOWN_CELL_ID}',
+ kind: 1,
+ languageId: 'markdown',
+ value: '# Focus persistence\\n\\nEdit me after reload.',
+ metadata: {},
+ outputs: []
+ }
+ ]
+ };
+ const notebookDoc = JSON.stringify(notebook);
+ const db = await openLocalDb();
+ await runTransaction(db, 'folders', 'readwrite', (store) => store.put({
+ id: 'local://folder/local',
+ name: 'Local Notebooks',
+ remoteId: '',
+ children: ['${NOTEBOOK_URI}'],
+ lastSynced: ''
+ }));
+ await runTransaction(db, 'files', 'readwrite', (store) => store.put({
+ id: '${NOTEBOOK_URI}',
+ name: '${NOTEBOOK_NAME}',
+ remoteId: '${NOTEBOOK_URI}',
+ lastSynced: '',
+ lastRemoteChecksum: '',
+ doc: notebookDoc,
+ md5Checksum: ''
+ }));
+ db.close();
+ localStorage.setItem('runme/openNotebooks', JSON.stringify([
+ { uri: '${NOTEBOOK_URI}', name: '${NOTEBOOK_NAME}', type: 'file', children: [], parents: ['local://folder/local'] }
+ ]));
+ localStorage.setItem('runme/currentDoc', '${NOTEBOOK_URI}');
+ localStorage.removeItem('${STORAGE_KEY}');
+ return JSON.stringify({ status: 'ok' });
+ })()"`,
+ ).stdout,
+ );
+ writeArtifact("scenario-notebook-focus-persistence-01-seed.json", seedResult);
+ if (seedResult.includes('"status":"ok"')) {
+ pass("Seeded local markdown notebook");
+ } else {
+ fail("Failed to seed local markdown notebook");
+ }
+
+ run("agent-browser reload");
+ run("agent-browser wait 2400");
+
+ const openEditorResult = run(
+ `agent-browser eval "(async () => {
+ const rendered = document.querySelector('#markdown-rendered-${MARKDOWN_CELL_ID}');
+ if (!(rendered instanceof HTMLElement)) return 'missing-rendered-markdown';
+ rendered.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ return document.querySelector('#markdown-editor-${MARKDOWN_CELL_ID}')
+ ? 'ok'
+ : 'missing-markdown-editor';
+ })()"`,
+ ).stdout.trim();
+ if (openEditorResult.includes("ok")) {
+ pass("Opened markdown cell in editor mode");
+ } else {
+ fail("Failed to open markdown cell in editor mode");
+ }
+
+ run("agent-browser wait 600");
+
+ run(
+ `agent-browser eval "(async () => {
+ const textarea = document.querySelector('#markdown-editor-${MARKDOWN_CELL_ID} textarea');
+ if (!(textarea instanceof HTMLElement)) return 'missing-editor-textarea';
+ textarea.focus();
+ return document.activeElement === textarea ? 'ok' : 'focus-missed';
+ })()"`,
+ );
+ run("agent-browser wait 400");
+
+ const storageAfterEdit = decodeAgentBrowserEvalOutput(
+ run(
+ `agent-browser eval "${escapeDoubleQuotes(`(() => {
+ const raw = localStorage.getItem('${STORAGE_KEY}');
+ return raw || '';
+ })()`)}"`,
+ ).stdout,
+ );
+ writeArtifact(
+ "scenario-notebook-focus-persistence-02-storage-after-edit.json",
+ storageAfterEdit,
+ );
+ if (storageAfterEdit.includes(MARKDOWN_CELL_ID) && storageAfterEdit.includes('"focusRole":"editor"')) {
+ pass("Persisted active markdown editor state");
+ } else {
+ fail("Did not persist active markdown editor state");
+ }
+
+ run("agent-browser reload");
+ run("agent-browser wait 2400");
+
+ const afterReloadSnapshot = run("agent-browser snapshot -i").stdout;
+ writeArtifact(
+ "scenario-notebook-focus-persistence-03-after-reload-snapshot.txt",
+ afterReloadSnapshot,
+ );
+
+ const afterReloadState = decodeAgentBrowserEvalOutput(
+ run(
+ `agent-browser eval "${escapeDoubleQuotes(`(() => {
+ const editor = document.querySelector('#markdown-editor-${MARKDOWN_CELL_ID}');
+ const rendered = document.querySelector('#markdown-rendered-${MARKDOWN_CELL_ID}');
+ const active = document.activeElement;
+ return JSON.stringify({
+ hasEditor: Boolean(editor),
+ hasRendered: Boolean(rendered),
+ activeElementTag: active?.tagName ?? '',
+ activeElementId: active?.id ?? '',
+ });
+ })()`)}"`,
+ ).stdout,
+ );
+ writeArtifact(
+ "scenario-notebook-focus-persistence-04-after-reload-state.json",
+ afterReloadState,
+ );
+ run(`agent-browser screenshot ${join(OUTPUT_DIR, "scenario-notebook-focus-persistence-05-after-reload.png")}`);
+
+ if (afterReloadState.includes('"hasEditor":true') && !afterReloadState.includes('"hasRendered":true')) {
+ pass("Reload restored markdown cell in editor mode");
+ } else {
+ fail("Reload did not restore markdown cell in editor mode");
+ }
+} catch (error) {
+ fail(`Scenario execution error: ${String(error)}`);
+} finally {
+ if (startedRecording) {
+ run("agent-browser record stop", {
+ timeoutMs: 30000,
+ throwOnAgentBrowserTimeout: false,
+ });
+ }
+ if (!AGENT_BROWSER_KEEP_OPEN) {
+ run("agent-browser close");
+ }
+}
+
+console.log(`Movie: ${MOVIE_PATH}`);
+console.log(`Assertions: ${totalCount}, Passed: ${passCount}, Failed: ${failCount}`);
+process.exit(failCount > 0 ? 1 : 0);
diff --git a/docs-dev/design/20260514_notebook_focus_persistence.md b/docs-dev/design/20260514_notebook_focus_persistence.md
new file mode 100644
index 0000000..8e9d581
--- /dev/null
+++ b/docs-dev/design/20260514_notebook_focus_persistence.md
@@ -0,0 +1,149 @@
+# Notebook Focus Persistence
+
+## Scope
+
+This document proposes a simpler model for restoring notebook cell focus across:
+
+- switching away from the browser tab and back
+- switching between notebook tabs in the app
+- refreshing the page
+
+The target behavior is:
+
+1. Each open notebook remembers its last active cell.
+2. Markdown cells remember whether the last active surface was the editor or the
+ rendered view.
+3. Returning to the browser tab restores focus to the visible notebook's last
+ active cell.
+4. Refreshing the page restores the same cell and reopens markdown in edit mode
+ when the editor was last active.
+
+## Current Problem
+
+The current implementation models focus restore as a React event counter.
+
+That creates two problems:
+
+- it mixes durable UI state with transient focus events
+- it lets unrelated re-renders replay restore logic
+
+The reviewer concern is valid. If restore is encoded as `restoreFocusRequest`,
+the code must answer "has this request already been consumed?" in every effect
+that reads it. That makes markdown edit/render behavior fragile.
+
+## Decision
+
+We will persist per-notebook active-cell UI state in `localStorage`.
+
+We will not persist focus-restore event counters.
+
+We will track window/tab focus separately as transient React state and use it
+only to decide when to apply the already-persisted active-cell state.
+
+## Why `localStorage`
+
+`localStorage` is the right default here because:
+
+- the state is tiny
+- it is JSON-shaped
+- it must be readable during initial render
+- it should survive refresh
+- it does not need IndexedDB queries or transactions
+
+IndexedDB remains the right place for notebook content and mirrored storage.
+This feature is UI state, not notebook data.
+
+## Stored Shape
+
+Suggested payload:
+
+```ts
+type CellFocusRole = "editor" | "rendered";
+
+interface NotebookActiveCellState {
+ refId: string;
+ focusRole: CellFocusRole;
+ updatedAt: string;
+}
+
+type NotebookActiveCellMap = Record;
+```
+
+Storage key:
+
+```ts
+const STORAGE_KEY = "runme/notebook-active-cells";
+```
+
+The map is keyed by notebook URI.
+
+## Behavior
+
+### On cell focus
+
+When focus moves within a notebook cell:
+
+- record the notebook URI
+- record the cell ref id
+- record whether focus is in the editor or rendered markdown surface
+- persist the map to `localStorage`
+
+### On browser focus return
+
+When the browser tab becomes focused and visible:
+
+- look at the visible notebook tab, not `currentDoc`
+- read that notebook's stored active-cell state
+- if the stored cell still exists, restore focus to it
+- if the stored focus role is `editor` for a markdown cell, reopen that cell in
+ edit mode before focusing it
+- if the visible tab is a non-notebook synthetic tab, do nothing
+
+### On page refresh
+
+During initial render of a notebook tab:
+
+- read stored active-cell state for that notebook
+- if the active cell is a markdown cell and the stored role is `editor`, mount
+ that cell in edit mode
+- if the page is already focused, place focus back into that cell
+
+### On stale state
+
+If the stored cell no longer exists:
+
+- ignore the stored entry
+- fall back to normal rendering
+
+We do not need aggressive cleanup when notebooks close. Keeping the last active
+cell for a notebook is useful if the user reopens it later.
+
+## Implementation Plan
+
+1. Add a small helper module for reading and writing the persisted map.
+2. Load persisted active-cell state in `Actions`.
+3. Replace restore counters with:
+ - durable active-cell state from storage
+ - transient `window focused` state
+4. Change `MarkdownCell` to derive initial edit mode from persisted active-cell
+ state instead of replaying a request counter.
+5. Add focused unit tests for:
+ - storage normalization
+ - markdown initial edit-mode restore
+ - focus restore on browser refocus without replaying on every keystroke
+6. Verify behavior in a browser scenario and record a video artifact.
+
+## Tradeoffs
+
+- `localStorage` is synchronous. That is acceptable because the payload is tiny
+ and the feature is startup-sensitive.
+- The stored state is browser-local. That is correct for per-browser UI focus.
+- We still keep a small amount of React state for "is the browser focused?".
+ That state is transient and does not encode restore events, which keeps the
+ model simpler than a counter.
+
+## Recommendation
+
+Persist `{ docUri -> activeCellId, focusRole }` in `localStorage` and treat
+window focus as a transient signal that reapplies that durable state to the
+visible notebook only.