From c594cb467cc5d6dd3f53e0a729c9e4589f5d38cc Mon Sep 17 00:00:00 2001
From: imjszhang
Date: Wed, 22 Jul 2026 16:16:06 +0800
Subject: [PATCH 1/3] feat(notebook): import ipynb sessions
Co-authored-by: Cursor
---
src/main/notebook/ipc.test.ts | 10 +
src/main/notebook/ipc.ts | 6 +
src/main/notebook/ipynb-import.test.ts | 207 ++++++++++++++++++
src/main/notebook/ipynb-import.ts | 206 +++++++++++++++++
src/main/notebook/repository.ts | 20 ++
.../notebook/runtime-service.import.test.ts | 133 +++++++++++
src/main/notebook/runtime-service.ts | 74 ++++++-
src/preload/index.d.ts | 2 +
src/preload/index.ts | 4 +
...SessionNotebookDialog.interaction.test.tsx | 46 +++-
.../SessionNotebookDialog.render.test.tsx | 10 +-
.../pages/workspace/SessionNotebookDialog.tsx | 56 ++++-
src/renderer/web/api-map.generated.ts | 1 +
src/shared/notebook.ts | 17 +-
14 files changed, 781 insertions(+), 11 deletions(-)
create mode 100644 src/main/notebook/ipynb-import.test.ts
create mode 100644 src/main/notebook/ipynb-import.ts
create mode 100644 src/main/notebook/runtime-service.import.test.ts
diff --git a/src/main/notebook/ipc.test.ts b/src/main/notebook/ipc.test.ts
index c1ddcae3f..26b8c456a 100644
--- a/src/main/notebook/ipc.test.ts
+++ b/src/main/notebook/ipc.test.ts
@@ -51,6 +51,7 @@ describe('notebook IPC handlers', () => {
}),
runCell: vi.fn().mockResolvedValue({ runId: 'run-2', status: 'completed' }),
exportIpynb: vi.fn().mockResolvedValue({ saved: true, filePath: '/tmp/session.ipynb' }),
+ importIpynb: vi.fn().mockResolvedValue({ imported: true, cellCount: 1 }),
beginCodeCell: vi.fn().mockResolvedValue({ cellId: 'cell-1', writeId: 'write-1' }),
appendCodeCell: vi.fn().mockResolvedValue({ receivedBytes: 5 }),
finishCodeCell: vi.fn().mockResolvedValue({ status: 'idle' }),
@@ -94,6 +95,7 @@ describe('notebook IPC handlers', () => {
workspaceCwd: '/workspace',
kernel: 'python'
})
+ await handlers.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' })
expect(service.execute).toHaveBeenCalledWith({
sessionId: 'session-1',
@@ -120,6 +122,10 @@ describe('notebook IPC handlers', () => {
workspaceCwd: '/workspace',
kernel: 'python'
})
+ expect(service.importIpynb).toHaveBeenCalledWith({
+ sessionId: 'session-1',
+ workspaceCwd: '/workspace'
+ })
})
it('registers every notebook channel and forwards the renderer payload unchanged', async () => {
@@ -132,6 +138,7 @@ describe('notebook IPC handlers', () => {
runCell: vi.fn().mockResolvedValue({ runId: 'run-1', status: 'completed' }),
execute: vi.fn().mockResolvedValue({ runId: 'run-2', status: 'completed' }),
exportIpynb: vi.fn().mockResolvedValue({ saved: false }),
+ importIpynb: vi.fn().mockResolvedValue({ imported: false }),
restart: vi.fn().mockResolvedValue({ sessionId: 'session-1' }),
shutdown: vi.fn().mockResolvedValue({ sessionId: 'session-1', status: 'shutdown' })
} as unknown as NotebookRuntimeService
@@ -147,6 +154,7 @@ describe('notebook IPC handlers', () => {
'notebook:execute',
'notebook:export-ipynb',
'notebook:export-ipynb-all',
+ 'notebook:import-ipynb',
'notebook:restart',
'notebook:shutdown'
])
@@ -178,6 +186,7 @@ describe('notebook IPC handlers', () => {
await ipcHandlers.get('notebook:run-cell')?.(undefined, run)
await ipcHandlers.get('notebook:execute')?.(undefined, execute)
await ipcHandlers.get('notebook:export-ipynb')?.(undefined, session)
+ await ipcHandlers.get('notebook:import-ipynb')?.(undefined, session)
await ipcHandlers.get('notebook:restart')?.(undefined, session)
await ipcHandlers.get('notebook:shutdown')?.(undefined, session)
@@ -189,6 +198,7 @@ describe('notebook IPC handlers', () => {
expect(service.runCell).toHaveBeenCalledWith(publicRun)
expect(service.execute).toHaveBeenCalledWith(publicExecute)
expect(service.exportIpynb).toHaveBeenCalledWith(session)
+ expect(service.importIpynb).toHaveBeenCalledWith(session)
expect(service.restart).toHaveBeenCalledWith(session)
expect(service.shutdown).toHaveBeenCalledWith(session)
})
diff --git a/src/main/notebook/ipc.ts b/src/main/notebook/ipc.ts
index 2d5f1773f..b67518653 100644
--- a/src/main/notebook/ipc.ts
+++ b/src/main/notebook/ipc.ts
@@ -9,6 +9,7 @@ import type {
ExportNotebookKernelRequest,
ExportNotebookResult,
FinishNotebookCodeCellRequest,
+ ImportNotebookResult,
NotebookRunSummary,
NotebookSessionReference,
NotebookSessionRequest,
@@ -34,6 +35,7 @@ type NotebookHandlers = {
execute: (request: ExecuteNotebookCodeRequest) => Promise
exportIpynb: (request: ExportNotebookKernelRequest) => Promise
exportIpynbAll: (request: ExportNotebookAllRequest) => Promise
+ importIpynb: (request: NotebookSessionRequest) => Promise
restart: (request: NotebookSessionRequest) => Promise
shutdown: (request: NotebookSessionRequest) => ReturnType
}
@@ -61,6 +63,7 @@ const createNotebookHandlers = (service: NotebookRuntimeService): NotebookHandle
withDataRootWrite(() => service.execute(withoutTrustedTurnContext(request))),
exportIpynb: (request) => service.exportIpynb(request),
exportIpynbAll: (request) => service.exportIpynbAll(request),
+ importIpynb: (request) => service.importIpynb(request),
restart: (request) => withDataRootWrite(() => service.restart(request)),
shutdown: (request) => withDataRootWrite(() => service.shutdown(request))
})
@@ -96,6 +99,9 @@ const registerNotebookIpcHandlers = (service: NotebookRuntimeService): void => {
ipcMain.handle('notebook:export-ipynb-all', (_event, request: ExportNotebookAllRequest) =>
handlers.exportIpynbAll(request)
)
+ ipcMain.handle('notebook:import-ipynb', (_event, request: NotebookSessionRequest) =>
+ handlers.importIpynb(request)
+ )
ipcMain.handle('notebook:restart', (_event, request: NotebookSessionRequest) =>
handlers.restart(request)
)
diff --git a/src/main/notebook/ipynb-import.test.ts b/src/main/notebook/ipynb-import.test.ts
new file mode 100644
index 000000000..e86d9ef54
--- /dev/null
+++ b/src/main/notebook/ipynb-import.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest'
+
+import type { NotebookRunDocument } from '../../shared/notebook'
+import { runDocumentToIpynb } from './ipynb-export'
+import { ipynbToRunRecords, type IpynbImportResult } from './ipynb-import'
+
+const context = {
+ importedAt: 1_000,
+ createId: (() => {
+ let value = 0
+ return () => String(++value)
+ })()
+}
+
+const importNotebook = (notebook: unknown): IpynbImportResult =>
+ ipynbToRunRecords(notebook, {
+ importedAt: context.importedAt,
+ createId: context.createId
+ })
+
+describe('ipynbToRunRecords', () => {
+ it('validates the nbformat major version and cells array', () => {
+ expect(() => importNotebook({ nbformat: 3, cells: [] })).toThrow('expected nbformat 4')
+ expect(() => importNotebook({ nbformat: 4 })).toThrow('cells must be an array')
+ })
+
+ it('imports code cells, skips markdown, and reconstructs all supported outputs', () => {
+ const result = importNotebook({
+ nbformat: 4,
+ nbformat_minor: 5,
+ metadata: { kernelspec: { name: 'python3', language: 'python' } },
+ cells: [
+ { cell_type: 'markdown', source: ['# Title\n'] },
+ {
+ cell_type: 'code',
+ source: ['print("hello")\n', '2 + 2'],
+ execution_count: 4,
+ metadata: {
+ open_science: { kernel: 'python', environment: 'analysis' }
+ },
+ outputs: [
+ { output_type: 'stream', name: 'stdout', text: ['hello\n'] },
+ { output_type: 'stream', name: 'stderr', text: 'warning\n' },
+ {
+ output_type: 'error',
+ ename: 'ValueError',
+ evalue: 'bad value',
+ traceback: ['line 1\n', 'line 2']
+ },
+ {
+ output_type: 'display_data',
+ data: { 'image/png': 'aW1hZ2U=', 'text/plain': '' },
+ metadata: {}
+ },
+ {
+ output_type: 'display_data',
+ data: { 'application/json': { answer: 42 } },
+ metadata: {}
+ },
+ {
+ output_type: 'execute_result',
+ data: { 'text/plain': ['4'] },
+ metadata: {},
+ execution_count: 4
+ }
+ ]
+ }
+ ]
+ })
+
+ expect(result.skippedCellCount).toBe(1)
+ expect(result.runs).toHaveLength(1)
+ expect(result.runs[0]).toMatchObject({
+ source: 'user',
+ inputKind: 'cell',
+ kernelKind: 'python',
+ script: 'print("hello")\n2 + 2',
+ status: 'imported',
+ startedAt: 1_000,
+ executionCount: 4,
+ environment: 'analysis',
+ text: {
+ stdout: 'hello\n',
+ stderr: 'warning\n',
+ traceback: 'line 1\nline 2'
+ }
+ })
+ expect(result.runs[0].outputs).toEqual([
+ { type: 'stream', name: 'stdout', text: 'hello\n' },
+ { type: 'stream', name: 'stderr', text: 'warning\n' },
+ {
+ type: 'error',
+ name: 'ValueError',
+ message: 'bad value',
+ traceback: 'line 1\nline 2'
+ },
+ { type: 'display', data: { 'image/png': 'aW1hZ2U=', 'text/plain': '' } },
+ { type: 'json', data: { answer: 42 } },
+ { type: 'text', text: '4' }
+ ])
+ })
+
+ it('uses kernelspec fallback and restores downgraded bash/repl source markers', () => {
+ const result = importNotebook({
+ nbformat: 4,
+ metadata: { kernelspec: { name: 'ir', language: 'R' } },
+ cells: [
+ {
+ cell_type: 'code',
+ source: 'print(1)',
+ execution_count: null,
+ metadata: {},
+ outputs: []
+ },
+ {
+ cell_type: 'code',
+ source: ['%%bash\n', 'pwd'],
+ execution_count: null,
+ metadata: { tags: ['open-science-bash'] },
+ outputs: []
+ },
+ {
+ cell_type: 'code',
+ source: '%%javascript\nawait host.mcp()',
+ execution_count: null,
+ metadata: { open_science: { kernel: 'repl' } },
+ outputs: []
+ }
+ ]
+ })
+
+ expect(result.runs.map(({ kernelKind, script }) => ({ kernelKind, script }))).toEqual([
+ { kernelKind: 'r', script: 'print(1)' },
+ { kernelKind: 'bash', script: 'pwd' },
+ { kernelKind: 'repl', script: 'await host.mcp()' }
+ ])
+ })
+
+ it('round-trips the supported Open Science subset in both directions', async () => {
+ const source = {
+ nbformat: 4,
+ nbformat_minor: 5,
+ metadata: { kernelspec: { display_name: 'Python 3', name: 'python3', language: 'python' } },
+ cells: [
+ {
+ cell_type: 'code',
+ id: 'source-cell',
+ source: ['x = 1\n', 'x'],
+ execution_count: 7,
+ metadata: { open_science: { kernel: 'python', environment: 'analysis' } },
+ outputs: [
+ {
+ output_type: 'execute_result',
+ data: { 'text/plain': '1' },
+ metadata: {},
+ execution_count: 7
+ }
+ ]
+ }
+ ]
+ }
+ const imported = importNotebook(source)
+ const document: NotebookRunDocument = {
+ version: 1,
+ projectName: 'default-project',
+ sessionId: 'session-1',
+ workspaceCwd: '/workspace',
+ notebookSessionRoot: '/storage/notebooks/default-project/session-1',
+ dataRoot: '/storage/notebooks/default-project/session-1/data',
+ kernel: {
+ language: 'python',
+ kernelName: 'python3',
+ runtimeRoot: '/storage/runtime',
+ lastKnownStatus: 'idle'
+ },
+ runs: imported.runs,
+ updatedAt: 1_000
+ }
+
+ const exported = await runDocumentToIpynb(document)
+ expect(exported.cells[0]).toMatchObject({
+ source: source.cells[0].source,
+ execution_count: 7,
+ outputs: source.cells[0].outputs,
+ metadata: {
+ open_science: { kernel: 'python', environment: 'analysis', status: 'imported' }
+ }
+ })
+
+ const reimported = importNotebook(exported)
+ expect(
+ reimported.runs.map(({ script, kernelKind, executionCount, outputs }) => ({
+ script,
+ kernelKind,
+ executionCount,
+ outputs
+ }))
+ ).toEqual(
+ imported.runs.map(({ script, kernelKind, executionCount, outputs }) => ({
+ script,
+ kernelKind,
+ executionCount,
+ outputs
+ }))
+ )
+ })
+})
diff --git a/src/main/notebook/ipynb-import.ts b/src/main/notebook/ipynb-import.ts
new file mode 100644
index 000000000..24226a6e7
--- /dev/null
+++ b/src/main/notebook/ipynb-import.ts
@@ -0,0 +1,206 @@
+import type {
+ NotebookKernelKind,
+ NotebookOutput,
+ NotebookRunRecord,
+ NotebookTextOutput
+} from '../../shared/notebook'
+
+type IpynbImportContext = {
+ createId: () => string
+ importedAt: number
+}
+
+type IpynbImportResult = {
+ runs: NotebookRunRecord[]
+ skippedCellCount: number
+}
+
+type JsonObject = Record
+
+const isObject = (value: unknown): value is JsonObject =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+
+const textValue = (value: unknown, field: string): string => {
+ if (typeof value === 'string') return value
+ if (Array.isArray(value) && value.every((part) => typeof part === 'string')) {
+ return value.join('')
+ }
+ throw new Error(`Invalid .ipynb ${field}: expected a string or string array.`)
+}
+
+const optionalObject = (value: unknown): JsonObject => (isObject(value) ? value : {})
+
+const notebookKernel = (notebook: JsonObject): 'python' | 'r' => {
+ const metadata = optionalObject(notebook.metadata)
+ const kernelspec = optionalObject(metadata.kernelspec)
+ const name = typeof kernelspec.name === 'string' ? kernelspec.name.toLowerCase() : ''
+ const language = typeof kernelspec.language === 'string' ? kernelspec.language.toLowerCase() : ''
+ return name === 'ir' || language === 'r' ? 'r' : 'python'
+}
+
+const cellKernel = (cell: JsonObject, fallback: 'python' | 'r'): NotebookKernelKind => {
+ const metadata = optionalObject(cell.metadata)
+ const openScience = optionalObject(metadata.open_science)
+ const kernel = openScience.kernel
+ if (kernel === 'python' || kernel === 'r' || kernel === 'repl' || kernel === 'bash') {
+ return kernel
+ }
+ const tags = Array.isArray(metadata.tags) ? metadata.tags : []
+ if (tags.includes('open-science-bash')) return 'bash'
+ if (tags.includes('open-science-repl')) return 'repl'
+ return fallback
+}
+
+const stripKernelMarker = (source: string, kernel: NotebookKernelKind): string => {
+ if (kernel === 'bash' && source.startsWith('%%bash\n')) return source.slice('%%bash\n'.length)
+ if (kernel === 'repl' && source.startsWith('%%javascript\n')) {
+ return source.slice('%%javascript\n'.length)
+ }
+ return source
+}
+
+const mimeText = (value: unknown): string | null => {
+ if (typeof value === 'string') return value
+ if (Array.isArray(value) && value.every((part) => typeof part === 'string')) {
+ return value.join('')
+ }
+ return null
+}
+
+const mapDisplayData = (dataValue: unknown, executeResult: boolean): NotebookOutput | null => {
+ if (!isObject(dataValue)) return null
+ const entries = Object.entries(dataValue)
+ if (entries.length === 1 && entries[0][0] === 'application/json') {
+ return { type: 'json', data: entries[0][1] }
+ }
+ if (executeResult && entries.length === 1 && entries[0][0] === 'text/plain') {
+ const text = mimeText(entries[0][1])
+ return text === null ? null : { type: 'text', text }
+ }
+
+ const data: Record = {}
+ for (const [mime, value] of entries) {
+ const text = mimeText(value)
+ if (text !== null) data[mime] = text
+ }
+ return Object.keys(data).length > 0 ? { type: 'display', data } : null
+}
+
+const mapOutput = (value: unknown): NotebookOutput | null => {
+ if (!isObject(value) || typeof value.output_type !== 'string') return null
+ switch (value.output_type) {
+ case 'stream': {
+ const name = value.name === 'stderr' ? 'stderr' : 'stdout'
+ return { type: 'stream', name, text: textValue(value.text, 'stream output') }
+ }
+ case 'error':
+ return {
+ type: 'error',
+ name: typeof value.ename === 'string' ? value.ename : undefined,
+ message: typeof value.evalue === 'string' ? value.evalue : undefined,
+ traceback: textValue(value.traceback ?? '', 'error traceback')
+ }
+ case 'display_data':
+ return mapDisplayData(value.data, false)
+ case 'execute_result':
+ return mapDisplayData(value.data, true)
+ default:
+ return null
+ }
+}
+
+const textProjection = (outputs: NotebookOutput[]): NotebookTextOutput => {
+ const stdout = outputs
+ .filter(
+ (output): output is Extract =>
+ output.type === 'stream' && output.name === 'stdout'
+ )
+ .map((output) => output.text)
+ .join('')
+ const stderr = outputs
+ .filter(
+ (output): output is Extract =>
+ output.type === 'stream' && output.name === 'stderr'
+ )
+ .map((output) => output.text)
+ .join('')
+ const traceback = outputs
+ .filter(
+ (output): output is Extract => output.type === 'error'
+ )
+ .map((output) => output.traceback)
+ .join('\n')
+
+ return {
+ stdout,
+ stderr,
+ traceback,
+ plain: [stdout, stderr].filter((text) => text.trim().length > 0)
+ }
+}
+
+const readExecutionCount = (value: unknown): number | undefined =>
+ typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined
+
+// Parses the supported nbformat 4 subset into durable, not-yet-executed run records. ID/time
+// generation is injected so the projection is deterministic in tests.
+const ipynbToRunRecords = (
+ notebookValue: unknown,
+ context: IpynbImportContext
+): IpynbImportResult => {
+ if (!isObject(notebookValue) || notebookValue.nbformat !== 4) {
+ throw new Error('Unsupported .ipynb format: expected nbformat 4.')
+ }
+ if (!Array.isArray(notebookValue.cells)) {
+ throw new Error('Invalid .ipynb: cells must be an array.')
+ }
+
+ const fallbackKernel = notebookKernel(notebookValue)
+ const runs: NotebookRunRecord[] = []
+ let skippedCellCount = 0
+
+ for (const cellValue of notebookValue.cells) {
+ if (!isObject(cellValue) || cellValue.cell_type !== 'code') {
+ skippedCellCount += 1
+ continue
+ }
+ const kernelKind = cellKernel(cellValue, fallbackKernel)
+ const source = stripKernelMarker(textValue(cellValue.source, 'cell source'), kernelKind)
+ const outputs = Array.isArray(cellValue.outputs)
+ ? cellValue.outputs
+ .map(mapOutput)
+ .filter((output): output is NotebookOutput => output !== null)
+ : []
+ const metadata = optionalObject(cellValue.metadata)
+ const openScience = optionalObject(metadata.open_science)
+ const id = context.createId()
+ const environment =
+ (kernelKind === 'python' || kernelKind === 'r') &&
+ typeof openScience.environment === 'string' &&
+ openScience.environment.trim()
+ ? openScience.environment
+ : undefined
+
+ runs.push({
+ runId: `imported-run-${id}`,
+ cellId: `imported-cell-${id}`,
+ source: 'user',
+ inputKind: 'cell',
+ kernelKind,
+ script: source,
+ status: 'imported',
+ startedAt: context.importedAt,
+ executionCount: readExecutionCount(cellValue.execution_count),
+ text: textProjection(outputs),
+ outputs,
+ artifacts: [],
+ workingFiles: [],
+ ...(environment ? { environment } : {})
+ })
+ }
+
+ return { runs, skippedCellCount }
+}
+
+export { ipynbToRunRecords }
+export type { IpynbImportContext, IpynbImportResult }
diff --git a/src/main/notebook/repository.ts b/src/main/notebook/repository.ts
index ded834fa9..5ec032a97 100644
--- a/src/main/notebook/repository.ts
+++ b/src/main/notebook/repository.ts
@@ -28,6 +28,12 @@ type AppendNotebookRunRequest = {
run: NotebookRunRecord
}
+type AppendNotebookRunsRequest = {
+ projectName: string
+ sessionId: string
+ runs: NotebookRunRecord[]
+}
+
type UpdateNotebookRunRequest = AppendNotebookRunRequest
type UpdateKernelStatusRequest = {
@@ -220,6 +226,19 @@ class NotebookRunRepository {
}))
}
+ // Appends an imported notebook in one queued read-modify-write turn, avoiding one run.json rewrite
+ // per cell while preserving the same normalization and serialization guarantees as appendRun.
+ async appendRuns(request: AppendNotebookRunsRequest): Promise {
+ return this.mutate(request.projectName, request.sessionId, (document) => ({
+ ...document,
+ runs: [
+ ...document.runs,
+ ...request.runs.map((run) => normalizeRun(document.notebookSessionRoot, run))
+ ],
+ updatedAt: Date.now()
+ }))
+ }
+
// Replaces an existing execution record, used to turn the initial "running" entry final.
async updateRun(request: UpdateNotebookRunRequest): Promise {
return this.mutate(request.projectName, request.sessionId, (document) => {
@@ -403,6 +422,7 @@ export {
}
export type {
AppendNotebookRunRequest,
+ AppendNotebookRunsRequest,
LoadNotebookRunDocumentRequest,
UpdateKernelStatusRequest,
UpdateNotebookRunRequest
diff --git a/src/main/notebook/runtime-service.import.test.ts b/src/main/notebook/runtime-service.import.test.ts
new file mode 100644
index 000000000..24438fa66
--- /dev/null
+++ b/src/main/notebook/runtime-service.import.test.ts
@@ -0,0 +1,133 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { NotebookRunDocument } from '../../shared/notebook'
+import type { NotebookRunRepository } from './repository'
+import { NotebookRuntimeService } from './runtime-service'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+const document = (root: string): NotebookRunDocument => ({
+ version: 1,
+ projectName: 'default-project',
+ sessionId: 'session-1',
+ workspaceCwd: '/workspace',
+ notebookSessionRoot: join(root, 'notebooks', 'default-project', 'session-1'),
+ dataRoot: join(root, 'notebooks', 'default-project', 'session-1', 'data'),
+ kernel: {
+ language: 'python',
+ kernelName: 'python3',
+ runtimeRoot: join(root, 'runtime'),
+ lastKnownStatus: 'idle'
+ },
+ runs: [],
+ updatedAt: 1
+})
+
+const executor = {
+ execute: vi.fn(),
+ shutdown: vi.fn().mockResolvedValue({ reaped: true })
+}
+
+describe('NotebookRuntimeService importIpynb', () => {
+ it('imports in one repository append and exposes data cells for rerun', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'open-science-ipynb-import-'))
+ roots.push(root)
+ const filePath = join(root, 'source.ipynb')
+ await writeFile(
+ filePath,
+ JSON.stringify({
+ nbformat: 4,
+ metadata: { kernelspec: { name: 'python3', language: 'python' } },
+ cells: [
+ {
+ cell_type: 'code',
+ source: 'print(1)',
+ execution_count: null,
+ metadata: {},
+ outputs: []
+ },
+ { cell_type: 'markdown', source: '# ignored' }
+ ]
+ })
+ )
+ const stored = document(root)
+ const repository = {
+ loadOrCreate: vi.fn().mockResolvedValue(stored),
+ appendRuns: vi.fn().mockImplementation(async ({ runs }) => ({ ...stored, runs }))
+ } as unknown as NotebookRunRepository
+ const service = new NotebookRuntimeService({
+ configRoot: join(root, 'config'),
+ dataRoot: root,
+ projectName: 'default-project',
+ repository,
+ executorFactory: () => executor,
+ pickIpynb: async () => filePath
+ })
+
+ const result = await service.importIpynb({
+ sessionId: 'session-1',
+ workspaceCwd: '/workspace'
+ })
+
+ expect(result).toEqual({ imported: true, cellCount: 1, skippedCellCount: 1 })
+ expect(repository.appendRuns).toHaveBeenCalledOnce()
+ const importedRuns = vi.mocked(repository.appendRuns).mock.calls[0][0].runs
+ expect(importedRuns[0]).toMatchObject({
+ script: 'print(1)',
+ status: 'imported',
+ kernelKind: 'python'
+ })
+ const state = await service.state({ sessionId: 'session-1', workspaceCwd: '/workspace' })
+ expect(state.cells).toEqual([
+ expect.objectContaining({
+ id: importedRuns[0].cellId,
+ language: 'python',
+ code: 'print(1)',
+ status: 'idle'
+ })
+ ])
+ })
+
+ it('returns a cancellation result without reading or creating a session', async () => {
+ const repository = {
+ loadOrCreate: vi.fn()
+ } as unknown as NotebookRunRepository
+ const service = new NotebookRuntimeService({
+ configRoot: '/config',
+ dataRoot: '/storage',
+ projectName: 'default-project',
+ repository,
+ pickIpynb: async () => null
+ })
+
+ await expect(
+ service.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' })
+ ).resolves.toEqual({ imported: false })
+ expect(repository.loadOrCreate).not.toHaveBeenCalled()
+ })
+
+ it('reports invalid JSON as an import error', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'open-science-ipynb-import-'))
+ roots.push(root)
+ const filePath = join(root, 'broken.ipynb')
+ await writeFile(filePath, '{')
+ const service = new NotebookRuntimeService({
+ configRoot: join(root, 'config'),
+ dataRoot: root,
+ projectName: 'default-project',
+ pickIpynb: async () => filePath
+ })
+
+ await expect(
+ service.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' })
+ ).rejects.toThrow('Could not read .ipynb')
+ })
+})
diff --git a/src/main/notebook/runtime-service.ts b/src/main/notebook/runtime-service.ts
index aea2144ad..e9bdc0be0 100644
--- a/src/main/notebook/runtime-service.ts
+++ b/src/main/notebook/runtime-service.ts
@@ -16,6 +16,7 @@ import type {
ExportNotebookKernelRequest,
ExportNotebookResult,
FinishNotebookCodeCellRequest,
+ ImportNotebookResult,
NotebookEnvironmentStatus,
NotebookEnvironmentManifest,
NotebookRunEnvironmentCapture,
@@ -49,6 +50,7 @@ import {
type NbformatOutput,
type ResolvedArtifact
} from './ipynb-export'
+import { ipynbToRunRecords } from './ipynb-import'
import { NotebookKernelExecutor, type NotebookKernelExecutorOptions } from './kernel-executor'
import { saveIpynbAll } from './save-ipynb-all'
import type { KernelProcessKind } from './kernel-executor'
@@ -404,6 +406,8 @@ type NotebookRuntimeServiceOptions = {
| 'markPackageMutationDirty'
| 'refreshAfterPackageMutation'
>
+ // Native file-picker seam for notebook import tests.
+ pickIpynb?: () => Promise
}
// The wire binding plus the interpreter override the executor needs. `resolvedInterpreter` is set only
@@ -474,6 +478,16 @@ const saveIpynbWithDialog = async (
return { saved: true, filePath }
}
+const pickIpynbWithDialog = async (): Promise => {
+ const { dialog } = await import('electron')
+ const { canceled, filePaths } = await dialog.showOpenDialog({
+ title: 'Import notebook',
+ properties: ['openFile'],
+ filters: [{ name: 'Jupyter Notebook', extensions: ['ipynb'] }]
+ })
+ return canceled ? null : (filePaths[0] ?? null)
+}
+
// Writes one .ipynb per data kernel under a user-picked directory. Used by the "Download all" path;
// the per-tab path (a single .ipynb) goes through `saveIpynbWithDialog` instead. The actual
// orchestration (directory picker, conflict check, partial-write cleanup) lives in save-ipynb-all
@@ -2496,6 +2510,52 @@ class NotebookRuntimeService {
return (this.options.saveIpynbAll ?? saveIpynbAll)(files)
}
+ // Imports code cells as durable, not-yet-executed records and exposes data-kernel cells for rerun.
+ async importIpynb(request: NotebookSessionRequest): Promise {
+ const filePath = await (this.options.pickIpynb ?? pickIpynbWithDialog)()
+ if (!filePath) return { imported: false }
+
+ let notebook: unknown
+ try {
+ notebook = JSON.parse(await readFile(filePath, 'utf8')) as unknown
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new Error(`Could not read .ipynb: ${message}`)
+ }
+
+ const imported = ipynbToRunRecords(notebook, {
+ createId: randomUUID,
+ importedAt: Date.now()
+ })
+ const session = await this.ensureSession(request)
+ if (imported.runs.length > 0) {
+ await this.repository.appendRuns({
+ projectName: session.projectName,
+ sessionId: session.sessionId,
+ runs: imported.runs
+ })
+ for (const run of imported.runs) {
+ if (run.kernelKind !== 'python' && run.kernelKind !== 'r') continue
+ session.cells.push({
+ id: run.cellId,
+ language: run.kernelKind,
+ code: run.script,
+ status: 'idle',
+ executionCount: run.executionCount,
+ latestRunId: run.runId
+ })
+ }
+ session.executionCount += imported.runs.length
+ this.notifyNotebookChanged(session)
+ }
+
+ return {
+ imported: true,
+ cellCount: imported.runs.length,
+ skippedCellCount: imported.skippedCellCount
+ }
+ }
+
// Replaces the interpreter process while preserving cells and durable run history. Prefers the
// executor's own in-place restart (keeps the same instance, e.g. NotebookKernelExecutor tears down
// and lazily respawns its loops) and only shuts down + recreates for executors that don't support it.
@@ -3702,7 +3762,19 @@ class NotebookRuntimeService {
dataRoot: document.dataRoot,
runtimeRoot: document.kernel.runtimeRoot,
runJsonPath: getNotebookRunJsonPath(this.options.dataRoot, projectName, request.sessionId),
- cells: [],
+ cells: document.runs
+ .filter(
+ (run) =>
+ run.status === 'imported' && (run.kernelKind === 'python' || run.kernelKind === 'r')
+ )
+ .map((run) => ({
+ id: run.cellId,
+ language: run.kernelKind as NotebookLanguage,
+ code: run.script,
+ status: 'idle' as const,
+ executionCount: run.executionCount,
+ latestRunId: run.runId
+ })),
executionCount: document.runs.length,
executor: this.createExecutor(request.sessionId, projectName),
executionQueues: new Map(),
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 0d3ef6306..b0c7dba86 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -71,6 +71,7 @@ import type {
ExportNotebookKernelRequest,
ExportNotebookResult,
FinishNotebookCodeCellRequest,
+ ImportNotebookResult,
NotebookLanguage,
NotebookRunSummary,
NotebookSessionReference,
@@ -560,6 +561,7 @@ interface OpenScienceAPI {
execute(request: ExecuteNotebookCodeRequest): Promise
exportIpynb(request: ExportNotebookKernelRequest): Promise
exportIpynbAll(request: ExportNotebookAllRequest): Promise
+ importIpynb(request: NotebookSessionRequest): Promise
restart(request: NotebookSessionRequest): Promise
shutdown(request: NotebookSessionRequest): Promise<{ sessionId: string; status: 'shutdown' }>
onAvailable(listener: AcpListener): RemoveListener
diff --git a/src/preload/index.ts b/src/preload/index.ts
index c8162179e..af6d08c2a 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -73,6 +73,7 @@ import type {
ExportNotebookKernelRequest,
ExportNotebookResult,
FinishNotebookCodeCellRequest,
+ ImportNotebookResult,
NotebookLanguage,
NotebookRunSummary,
NotebookSessionReference,
@@ -619,6 +620,7 @@ type OpenScienceAPI = {
execute: (request: ExecuteNotebookCodeRequest) => Promise
exportIpynb: (request: ExportNotebookKernelRequest) => Promise
exportIpynbAll: (request: ExportNotebookAllRequest) => Promise
+ importIpynb: (request: NotebookSessionRequest) => Promise
restart: (request: NotebookSessionRequest) => Promise
shutdown: (
request: NotebookSessionRequest
@@ -1254,6 +1256,8 @@ const api: OpenScienceAPI = {
ipcRenderer.invoke('notebook:export-ipynb', request) as Promise,
exportIpynbAll: (request) =>
ipcRenderer.invoke('notebook:export-ipynb-all', request) as Promise,
+ importIpynb: (request) =>
+ ipcRenderer.invoke('notebook:import-ipynb', request) as Promise,
restart: (request) =>
ipcRenderer.invoke('notebook:restart', request) as Promise,
shutdown: (request) =>
diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx
index 6a97e08dd..1310a9d97 100644
--- a/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx
+++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.interaction.test.tsx
@@ -51,6 +51,7 @@ describe('SessionNotebookContent export', () => {
onClose={vi.fn()}
onExport={onExport}
onExportAll={vi.fn()}
+ onImport={vi.fn()}
/>
)
})
@@ -72,7 +73,10 @@ describe('SessionNotebookContent export', () => {
it('passes the clicked tab kernel to the export callback after switching tabs', async () => {
const onExport = vi.fn().mockResolvedValue(undefined)
- const mixedRuns: NotebookRunRecord[] = [run, { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' }]
+ const mixedRuns: NotebookRunRecord[] = [
+ run,
+ { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' }
+ ]
await act(async () => {
root.render(
{
onClose={vi.fn()}
onExport={onExport}
onExportAll={vi.fn()}
+ onImport={vi.fn()}
/>
)
})
@@ -118,6 +123,7 @@ describe('SessionNotebookContent export', () => {
onClose={vi.fn()}
onExport={onExport}
onExportAll={vi.fn()}
+ onImport={vi.fn()}
/>
)
})
@@ -146,6 +152,7 @@ describe('SessionNotebookContent export', () => {
onClose={vi.fn()}
onExport={onExport}
onExportAll={vi.fn()}
+ onImport={vi.fn()}
/>
)
})
@@ -172,6 +179,7 @@ describe('SessionNotebookContent export', () => {
onClose={vi.fn()}
onExport={failingExport}
onExportAll={vi.fn()}
+ onImport={vi.fn()}
/>
)
})
@@ -195,6 +203,7 @@ describe('SessionNotebookContent export', () => {
onClose={vi.fn()}
onExport={vi.fn()}
onExportAll={vi.fn()}
+ onImport={vi.fn()}
/>
)
})
@@ -204,7 +213,10 @@ describe('SessionNotebookContent export', () => {
it('invokes onExportAll for the "Download all" button on mixed sessions', async () => {
const onExportAll = vi.fn().mockResolvedValue(undefined)
- const mixedRuns: NotebookRunRecord[] = [run, { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' }]
+ const mixedRuns: NotebookRunRecord[] = [
+ run,
+ { ...run, runId: 'r1', kernelKind: 'r', environment: 'default-r' }
+ ]
await act(async () => {
root.render(
{
onClose={vi.fn()}
onExport={vi.fn()}
onExportAll={onExportAll}
+ onImport={vi.fn()}
/>
)
})
@@ -241,6 +254,7 @@ describe('SessionNotebookContent export', () => {
onClose={vi.fn()}
onExport={vi.fn()}
onExportAll={onExportAll}
+ onImport={vi.fn()}
/>
)
})
@@ -251,4 +265,32 @@ describe('SessionNotebookContent export', () => {
expect(allButton).toBeNull()
expect(onExportAll).not.toHaveBeenCalled()
})
+
+ it('invokes import for an empty notebook and surfaces failures', async () => {
+ const onImport = vi.fn().mockRejectedValue(new Error('Invalid notebook'))
+ await act(async () => {
+ root.render(
+
+ )
+ })
+
+ const button = container.querySelector('button[aria-label="Import .ipynb"]')
+ expect(button?.disabled).toBe(false)
+ await act(async () => {
+ button?.click()
+ await Promise.resolve()
+ })
+
+ expect(onImport).toHaveBeenCalledOnce()
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe('Invalid notebook')
+ expect(button?.disabled).toBe(false)
+ })
})
diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx
index e68b4610a..c3c689f55 100644
--- a/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx
+++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx
@@ -28,7 +28,13 @@ const renderContent = (props: {
error?: string
}): string =>
renderToStaticMarkup(
-
+
)
describe('SessionNotebookContent', () => {
@@ -113,6 +119,8 @@ describe('SessionNotebookContent', () => {
)?.[0]
expect(populatedButton).not.toMatch(/\sdisabled(?:=|\s|>)/)
expect(emptyButton).toMatch(/\sdisabled(?:=|\s|>)/)
+ const emptyImportButton = empty.match(/
{
const request = {
- sessionId: session.id,
- projectName: session.projectId,
- workspaceCwd: session.cwd ?? ''
+ sessionId: dialogSession.id,
+ projectName: dialogSession.projectId,
+ workspaceCwd: dialogSession.cwd ?? ''
}
const result = await window.api.notebook.importIpynb(request)
- if (!result.imported) return
+ if (!result.imported) return undefined
setRuns(await loadSessionNotebookRuns(window.api.notebook, request))
setStatus('ready')
+ const summary = `Imported ${pluralize(result.cellCount, 'cell')}`
+ return result.skippedCellCount > 0
+ ? `${summary} (skipped ${result.skippedCellCount})`
+ : summary
}}
/>
) : null}
From 2d0d152a83fd368d562e2da22f42570a7c15c750 Mon Sep 17 00:00:00 2001
From: imjszhang
Date: Fri, 31 Jul 2026 01:49:55 +0800
Subject: [PATCH 3/3] fix(notebook): surface imported-run env notice and badge
parity
Return recorded-vs-bound env mismatches from importIpynb for the footer, and align Preview/Dialog imported badges with a re-run tooltip.
Co-authored-by: Cursor
---
.../notebook/runtime-service.import.test.ts | 83 +++++++++++++++++++
src/main/notebook/runtime-service.ts | 24 +++++-
.../NotebookPreview.cell.render.test.tsx | 34 ++++++++
.../src/pages/workspace/NotebookPreview.tsx | 13 ++-
.../SessionNotebookDialog.render.test.tsx | 23 ++++-
.../pages/workspace/SessionNotebookDialog.tsx | 36 ++++++--
src/shared/notebook.ts | 3 +
7 files changed, 207 insertions(+), 9 deletions(-)
create mode 100644 src/renderer/src/pages/workspace/NotebookPreview.cell.render.test.tsx
diff --git a/src/main/notebook/runtime-service.import.test.ts b/src/main/notebook/runtime-service.import.test.ts
index 24438fa66..8e6efa009 100644
--- a/src/main/notebook/runtime-service.import.test.ts
+++ b/src/main/notebook/runtime-service.import.test.ts
@@ -96,6 +96,89 @@ describe('NotebookRuntimeService importIpynb', () => {
])
})
+ it('surfaces an environment notice when recorded env differs from the bound default', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'open-science-ipynb-import-env-'))
+ roots.push(root)
+ const filePath = join(root, 'source.ipynb')
+ await writeFile(
+ filePath,
+ JSON.stringify({
+ nbformat: 4,
+ metadata: { kernelspec: { name: 'python3', language: 'python' } },
+ cells: [
+ {
+ cell_type: 'code',
+ source: 'print(1)',
+ execution_count: null,
+ metadata: { open_science: { kernel: 'python', environment: 'analysis' } },
+ outputs: []
+ }
+ ]
+ })
+ )
+ const stored = document(root)
+ const repository = {
+ loadOrCreate: vi.fn().mockResolvedValue(stored),
+ appendRuns: vi.fn().mockImplementation(async ({ runs }) => ({ ...stored, runs }))
+ } as unknown as NotebookRunRepository
+ const service = new NotebookRuntimeService({
+ configRoot: join(root, 'config'),
+ dataRoot: root,
+ projectName: 'default-project',
+ repository,
+ executorFactory: () => executor,
+ pickIpynb: async () => filePath
+ })
+
+ await expect(
+ service.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' })
+ ).resolves.toEqual({
+ imported: true,
+ cellCount: 1,
+ skippedCellCount: 0,
+ environmentNotice: { recorded: ['analysis'], bound: ['default-python'] }
+ })
+ })
+
+ it('omits environmentNotice when recorded env matches the bound default', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'open-science-ipynb-import-match-'))
+ roots.push(root)
+ const filePath = join(root, 'source.ipynb')
+ await writeFile(
+ filePath,
+ JSON.stringify({
+ nbformat: 4,
+ metadata: { kernelspec: { name: 'python3', language: 'python' } },
+ cells: [
+ {
+ cell_type: 'code',
+ source: 'print(1)',
+ execution_count: null,
+ metadata: { open_science: { kernel: 'python', environment: 'default-python' } },
+ outputs: []
+ }
+ ]
+ })
+ )
+ const stored = document(root)
+ const repository = {
+ loadOrCreate: vi.fn().mockResolvedValue(stored),
+ appendRuns: vi.fn().mockImplementation(async ({ runs }) => ({ ...stored, runs }))
+ } as unknown as NotebookRunRepository
+ const service = new NotebookRuntimeService({
+ configRoot: join(root, 'config'),
+ dataRoot: root,
+ projectName: 'default-project',
+ repository,
+ executorFactory: () => executor,
+ pickIpynb: async () => filePath
+ })
+
+ await expect(
+ service.importIpynb({ sessionId: 'session-1', workspaceCwd: '/workspace' })
+ ).resolves.toEqual({ imported: true, cellCount: 1, skippedCellCount: 0 })
+ })
+
it('returns a cancellation result without reading or creating a session', async () => {
const repository = {
loadOrCreate: vi.fn()
diff --git a/src/main/notebook/runtime-service.ts b/src/main/notebook/runtime-service.ts
index f8bf5cf29..0c6fd4bb6 100644
--- a/src/main/notebook/runtime-service.ts
+++ b/src/main/notebook/runtime-service.ts
@@ -2553,10 +2553,32 @@ class NotebookRuntimeService {
this.notifyNotebookChanged(session)
}
+ const recordedEnvs = new Set()
+ const boundEnvs = new Set()
+ for (const language of ['python', 'r'] as const) {
+ const languageRuns = imported.runs.filter((run) => run.kernelKind === language)
+ if (languageRuns.length === 0) continue
+ const bound = this.resolveRunEnv(session, language)
+ for (const run of languageRuns) {
+ if (run.environment && run.environment !== bound) {
+ recordedEnvs.add(run.environment)
+ boundEnvs.add(bound)
+ }
+ }
+ }
+
return {
imported: true,
cellCount: imported.runs.length,
- skippedCellCount: imported.skippedCellCount
+ skippedCellCount: imported.skippedCellCount,
+ ...(recordedEnvs.size > 0
+ ? {
+ environmentNotice: {
+ recorded: Array.from(recordedEnvs),
+ bound: Array.from(boundEnvs)
+ }
+ }
+ : {})
}
}
diff --git a/src/renderer/src/pages/workspace/NotebookPreview.cell.render.test.tsx b/src/renderer/src/pages/workspace/NotebookPreview.cell.render.test.tsx
new file mode 100644
index 000000000..04650c90c
--- /dev/null
+++ b/src/renderer/src/pages/workspace/NotebookPreview.cell.render.test.tsx
@@ -0,0 +1,34 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+
+import type { NotebookRunRecord } from '../../../../shared/notebook'
+import { NotebookRunCell } from './NotebookPreview'
+
+const makeRun = (overrides: Partial = {}): NotebookRunRecord => ({
+ runId: 'r1',
+ cellId: 'c1',
+ source: 'user',
+ kernelKind: 'python',
+ script: 'print(1)',
+ status: 'imported',
+ startedAt: 0,
+ executionCount: 1,
+ text: { stdout: '1\n', stderr: '', traceback: '', plain: ['1\n'] },
+ outputs: [{ type: 'stream', name: 'stdout', text: '1\n' }],
+ artifacts: [],
+ workingFiles: [],
+ ...overrides
+})
+
+describe('NotebookRunCell imported badge', () => {
+ it('renders the imported badge beside the you badge for source snapshots', () => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('>you<')
+ expect(html).toContain('data-testid="notebook-imported-badge"')
+ expect(html).toContain(
+ 'title="Snapshot from the source .ipynb — re-running appends a new run"'
+ )
+ expect(html).not.toContain('error (line')
+ })
+})
diff --git a/src/renderer/src/pages/workspace/NotebookPreview.tsx b/src/renderer/src/pages/workspace/NotebookPreview.tsx
index d73af1152..2920d7392 100644
--- a/src/renderer/src/pages/workspace/NotebookPreview.tsx
+++ b/src/renderer/src/pages/workspace/NotebookPreview.tsx
@@ -84,6 +84,9 @@ const getRunOutputText = (run: NotebookRunRecord | undefined): string => {
.join('\n')
}
+const IMPORTED_RUN_BADGE_TITLE =
+ 'Snapshot from the source .ipynb — re-running appends a new run'
+
// Displays one durable execution record from run.json in chronological order. The zero-based index
// is the cell number shown in [n], and a failed run marks the offending line.
const NotebookRunCell = ({
@@ -115,6 +118,14 @@ const NotebookRunCell = ({
) : (
error
)
+ ) : run.status === 'imported' ? (
+
+ imported
+
) : null}
{originLabel ? (
@@ -527,4 +538,4 @@ const NotebookPreview = ({ item }: NotebookPreviewProps): React.JSX.Element => {
)
}
-export { NotebookPreview }
+export { NotebookPreview, NotebookRunCell }
diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx
index 8f4ede421..95780ed2b 100644
--- a/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx
+++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.render.test.tsx
@@ -1,7 +1,10 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
-import { SessionNotebookContent } from './SessionNotebookDialog'
+import {
+ formatImportResultMessage,
+ SessionNotebookContent
+} from './SessionNotebookDialog'
import type { NotebookRunRecord } from '../../../../shared/notebook'
const makeRun = (overrides: Partial = {}): NotebookRunRecord => ({
@@ -109,9 +112,27 @@ describe('SessionNotebookContent', () => {
expect(html).toContain('data-testid="session-notebook-imported-badge"')
expect(html).toContain('imported')
+ expect(html).toContain(
+ 'title="Snapshot from the source .ipynb — re-running appends a new run"'
+ )
expect(html).not.toContain('error (line')
})
+ it('formats import result footer messages including env notices', () => {
+ expect(formatImportResultMessage({ imported: false })).toBeUndefined()
+ expect(
+ formatImportResultMessage({ imported: true, cellCount: 2, skippedCellCount: 0 })
+ ).toBe('Imported 2 cells')
+ expect(
+ formatImportResultMessage({
+ imported: true,
+ cellCount: 1,
+ skippedCellCount: 1,
+ environmentNotice: { recorded: ['analysis'], bound: ['default-python'] }
+ })
+ ).toBe('Imported 1 cell (skipped 1) — recorded env "analysis"; re-runs use default-python')
+ })
+
it('enables .ipynb export for a loaded notebook and disables it when empty', () => {
const populated = renderContent({
sessionId: 's1',
diff --git a/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx b/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx
index 084a96d99..623acb1b7 100644
--- a/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx
+++ b/src/renderer/src/pages/workspace/SessionNotebookDialog.tsx
@@ -9,7 +9,11 @@ import { cn } from '@/lib/utils'
import type { ChatSession } from '@/stores/session-store'
import { resolveDataKernelForTab } from '../../../../shared/notebook'
-import type { NotebookKernelKind, NotebookRunRecord } from '../../../../shared/notebook'
+import type {
+ ImportNotebookResult,
+ NotebookKernelKind,
+ NotebookRunRecord
+} from '../../../../shared/notebook'
import { NotebookCodeBlock } from './notebook-code'
import { NotebookRunOutputs } from './NotebookRunOutputs'
import { NotebookInputDataStrip } from './NotebookInputDataStrip'
@@ -35,6 +39,23 @@ const getErrorMessage = (error: unknown): string =>
const pluralize = (count: number, word: string): string =>
`${count} ${word}${count === 1 ? '' : 's'}`
+const IMPORTED_RUN_BADGE_TITLE =
+ 'Snapshot from the source .ipynb — re-running appends a new run'
+
+// Composes the Session Notebook footer status for an import result. Cancelled picks return
+// undefined; successful imports surface cell counts and any recorded-vs-bound env mismatch.
+const formatImportResultMessage = (result: ImportNotebookResult): string | undefined => {
+ if (!result.imported) return undefined
+ const summary = `Imported ${pluralize(result.cellCount, 'cell')}`
+ const withSkipped =
+ result.skippedCellCount > 0 ? `${summary} (skipped ${result.skippedCellCount})` : summary
+ const notice = result.environmentNotice
+ if (!notice || notice.recorded.length === 0) return withSkipped
+ const recorded = notice.recorded.map((name) => `"${name}"`).join(', ')
+ const bound = notice.bound.join(', ')
+ return `${withSkipped} — recorded env ${recorded}; re-runs use ${bound}`
+}
+
// One persisted run rendered as a notebook cell: header badges, code, and split stdout/stderr. The
// zero-based index is the cell number shown in [n], aligning the display with a notebook's cells.
const NotebookDialogCell = ({
@@ -69,6 +90,7 @@ const NotebookDialogCell = ({
imported
@@ -519,10 +541,7 @@ const SessionNotebookDialog = ({
if (!result.imported) return undefined
setRuns(await loadSessionNotebookRuns(window.api.notebook, request))
setStatus('ready')
- const summary = `Imported ${pluralize(result.cellCount, 'cell')}`
- return result.skippedCellCount > 0
- ? `${summary} (skipped ${result.skippedCellCount})`
- : summary
+ return formatImportResultMessage(result)
}}
/>
) : null}
@@ -532,4 +551,9 @@ const SessionNotebookDialog = ({
)
}
-export { NotebookDialogCell, SessionNotebookContent, SessionNotebookDialog }
+export {
+ formatImportResultMessage,
+ NotebookDialogCell,
+ SessionNotebookContent,
+ SessionNotebookDialog
+}
diff --git a/src/shared/notebook.ts b/src/shared/notebook.ts
index 2bf50993d..e6e3c2d97 100644
--- a/src/shared/notebook.ts
+++ b/src/shared/notebook.ts
@@ -514,6 +514,9 @@ export type ImportNotebookResult =
imported: true
cellCount: number
skippedCellCount: number
+ // Present only when imported python/r cells recorded env names that differ from the
+ // session's bound env for that language. Renderer composes the footer notice from it.
+ environmentNotice?: { recorded: string[]; bound: string[] }
}
// Starts a streamed code write into a notebook cell.