diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 5cb91326..9c81bb7b 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -1435,9 +1435,10 @@ export namespace Provider { ...model, providerID: "openai-codex", cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - // Codex OAuth advertises a separate 272k window even when the - // copied public-API entry has a million-token context. - limit: { ...model.limit, context: 272_000 }, + // Keep each model's catalog window. Flattening the whole Codex + // family to a legacy input allowance made flagship and mini + // models advertise the same, incorrect context in every picker. + limit: { ...model.limit }, // Codex advertises its own fast tier independently of the public // API catalog, so synthesize only the modes in the OAuth contract. modes: codexOAuthModes(model.id), diff --git a/backend/cli/src/science/kernel/registry.ts b/backend/cli/src/science/kernel/registry.ts index d221ed2d..67af45cd 100644 --- a/backend/cli/src/science/kernel/registry.ts +++ b/backend/cli/src/science/kernel/registry.ts @@ -14,6 +14,16 @@ export type KernelIdentity = { language: KernelLanguage } +type KernelCell = { + title: string | null + source: string | null + code: string + status: "running" | "succeeded" | "failed" + executionCount: number | null + messageID: string | null + callID: string | null +} + export class KernelStartupCancelled extends Error { constructor() { super("Kernel startup was cancelled before execution.") @@ -44,6 +54,7 @@ type Entry = { startedAt: number | null lastActivityAt: number | null authority: ExecutionAuthority.Decision | null + lastCell: KernelCell | null } type Pending = { @@ -90,6 +101,17 @@ export const KernelStatus = z.object({ started_at: z.number().nullable(), last_activity_at: z.number().nullable(), authority: ExecutionAuthority.Decision.nullable(), + last_cell: z + .object({ + title: z.string().nullable(), + source: z.string().nullable(), + code: z.string(), + status: z.enum(["running", "succeeded", "failed"]), + execution_count: z.number().int().positive().nullable(), + message_id: z.string().nullable(), + call_id: z.string().nullable(), + }) + .nullable(), // Live usage sampled at request time for running processes. Absent fields // mean the platform could not report them — render as unavailable, not 0. resources: z @@ -124,6 +146,7 @@ const records = Instance.state( entry.startedAt = null entry.lastActivityAt = Date.now() entry.authority = null + entry.lastCell = null await persist(entry) }), ) @@ -177,6 +200,7 @@ function restore(value: z.infer) { startedAt: null, lastActivityAt: value.last_activity_at, authority: null, + lastCell: null, } records().entries.set(id, entry) return entry @@ -219,6 +243,7 @@ const record = (identity: KernelIdentity) => { startedAt: null, lastActivityAt: null, authority: null, + lastCell: null, } records().entries.set(id, value) return value @@ -362,6 +387,7 @@ const entry = async (identity: KernelIdentity, _options?: KernelStartOptions) => value.startedAt = null value.lastActivityAt = Date.now() value.authority = authority + value.lastCell = null const drop = () => { if (records().starts.get(value.key)?.ticket === ticket) records().starts.delete(value.key) } @@ -468,49 +494,78 @@ export namespace KernelRuntime { const codeState = ProvenanceEnvelope.code(value.environment?.cwd ?? Instance.directory) const startedAt = Date.now() value.lastActivityAt = startedAt - return kernel.execute(code, options).then( - async (result) => { - // The count belongs to this cell, so capture it before the awaits below. - // `value.executionCount` is the kernel's running total and every cell - // queued behind this one advances it — reading it back after the persist - // reported the count of whichever cell had most recently finished. - const count = result.executionCount ?? value.executionCount + 1 - value.executionCount = count - const completedAt = Date.now() - value.lastActivityAt = completedAt - await persist(value) - const complete = { ...result, executionCount: count } - const node = await provenance( - identity, - value, - code, - startedAt, - completedAt, - codeState, - options?.origin, - complete, - ) - return { ...complete, provenanceID: node.id } - }, - async (error) => { - const completedAt = Date.now() - value.lastActivityAt = completedAt - if (kernel.crashed) value.state = "crashed" - await persist(value) - const node = await provenance( - identity, - value, - code, - startedAt, - completedAt, - codeState, - options?.origin, - undefined, - error, - ) - throw new KernelExecutionError(error, node.id) - }, - ) + const source = options?.origin?.source ?? (identity.name.startsWith("notebook:") ? identity.name.slice(9) : null) + const cell = (): KernelCell => ({ + title: options?.origin?.title?.trim().slice(0, 100) || null, + source, + code: code.length > 12_000 ? `${code.slice(0, 12_000)}\n\n... (truncated)` : code, + status: "running", + executionCount: Math.max(value.executionCount, value.lastCell?.executionCount ?? 0) + 1, + messageID: options?.origin?.messageID ?? null, + callID: options?.origin?.callID ?? null, + }) + const running: { cell?: KernelCell } = {} + return kernel + .execute(code, { + ...options, + onStart: () => { + running.cell = cell() + value.lastCell = running.cell + value.lastActivityAt = Date.now() + options?.onStart?.() + }, + }) + .then( + async (result) => { + // The count belongs to this cell, so capture it before the awaits below. + // `value.executionCount` is the kernel's running total and every cell + // queued behind this one advances it — reading it back after the persist + // reported the count of whichever cell had most recently finished. + const count = result.executionCount ?? value.executionCount + 1 + value.executionCount = count + const completedAt = Date.now() + value.lastActivityAt = completedAt + const completeCell: KernelCell = { + ...(running.cell ?? cell()), + status: result.ok ? "succeeded" : "failed", + executionCount: count, + } + if (!value.lastCell || value.lastCell === running.cell) value.lastCell = completeCell + await persist(value) + const complete = { ...result, executionCount: count } + const node = await provenance( + identity, + value, + code, + startedAt, + completedAt, + codeState, + options?.origin, + complete, + ) + return { ...complete, provenanceID: node.id } + }, + async (error) => { + const completedAt = Date.now() + value.lastActivityAt = completedAt + const failedCell: KernelCell = { ...(running.cell ?? cell()), status: "failed" } + if (!value.lastCell || value.lastCell === running.cell) value.lastCell = failedCell + if (kernel.crashed) value.state = "crashed" + await persist(value) + const node = await provenance( + identity, + value, + code, + startedAt, + completedAt, + codeState, + options?.origin, + undefined, + error, + ) + throw new KernelExecutionError(error, node.id) + }, + ) } export function active(identity: KernelIdentity) { @@ -544,6 +599,18 @@ export namespace KernelRuntime { started_at: active ? value.startedAt : null, last_activity_at: value.lastActivityAt, authority: value.authority, + last_cell: + active && value.lastCell + ? { + title: value.lastCell.title, + source: value.lastCell.source, + code: value.lastCell.code, + status: value.lastCell.status, + execution_count: value.lastCell.executionCount, + message_id: value.lastCell.messageID, + call_id: value.lastCell.callID, + } + : null, } } @@ -575,6 +642,7 @@ export namespace KernelRuntime { value.startedAt = null value.lastActivityAt = Date.now() value.authority = null + value.lastCell = null await persist(value) } diff --git a/backend/cli/src/science/kernel/types.ts b/backend/cli/src/science/kernel/types.ts index 9ecff1b6..c69ac480 100644 --- a/backend/cli/src/science/kernel/types.ts +++ b/backend/cli/src/science/kernel/types.ts @@ -84,8 +84,10 @@ export interface ExecuteOptions { signal?: AbortSignal /** Whether to capture rich (MIME) display outputs. Default true. */ rich?: boolean - /** Message and tool call that requested this execution, for lineage. */ - origin?: { messageID?: string; callID?: string } + /** Message, tool call, and human-facing cell identity used for lineage/UI. */ + origin?: { messageID?: string; callID?: string; title?: string; source?: string } + /** Internal lifecycle hook fired when a queued cell actually starts. */ + onStart?: () => void } export interface KernelStartOptions { diff --git a/backend/cli/src/server/routes/notebook.ts b/backend/cli/src/server/routes/notebook.ts index 71dde5d8..9e9feba3 100644 --- a/backend/cli/src/server/routes/notebook.ts +++ b/backend/cli/src/server/routes/notebook.ts @@ -399,7 +399,7 @@ export const NotebookRoutes = lazy(() => const result = await KernelRuntime.execute( identity(body), body.code, - { timeout: body.timeout }, + { timeout: body.timeout, origin: { source: body.id } }, { cwd: await SessionFilesystem.workspace(body.sessionID) }, ).catch((error) => { if (error instanceof KernelStartupCancelled) return error diff --git a/backend/cli/src/tool/notebook.ts b/backend/cli/src/tool/notebook.ts index f4b12f68..fd8f63de 100644 --- a/backend/cli/src/tool/notebook.ts +++ b/backend/cli/src/tool/notebook.ts @@ -373,6 +373,7 @@ class PythonKernel implements Kernel { private async run(code: string, opts?: ExecuteOptions): Promise { if (!this.ready) throw new Error("Python kernel is not running") + opts?.onStart?.() const proc = this.proc! this.lastUsed = Date.now() const timeout = Math.min(Math.max(opts?.timeout ?? 120_000, 5_000), 600_000) @@ -578,6 +579,8 @@ export const NotebookTool = Tool.define("notebook", { description: [ "Execute Python code in a persistent, managed kernel. Variables, imports, and state persist across calls that use the same kernel name.", "For multiple independent analyses, issue multiple notebook calls in the same response with distinct `kernel` names. Those kernels execute concurrently and appear separately in Compute.", + "Always set `title` to a concise description of the scientific action, not a code fragment or import.", + "Set `source` when the cell belongs to a script or .ipynb file so Compute can identify that source.", "Never use shell subprocesses to imitate multiple kernels; use this tool's `kernel` parameter instead.", "After a named analysis is fully saved and verified, call this tool with `action: stop` and the same kernel name so completed workers do not idle.", "Use instead of `bash python` for analysis — no need to re-import or re-load data between cells.", @@ -588,6 +591,20 @@ export const NotebookTool = Tool.define("notebook", { .object({ action: z.enum(["execute", "stop"]).optional().describe("Execute a cell (default) or stop this named kernel"), code: z.string().optional().describe("Python code to execute; required when action is execute"), + title: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe("Short action label for this cell, for example 'Benchmarking survival classifiers'"), + source: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe("Script or notebook path this cell belongs to, when applicable"), kernel: z .string() .trim() @@ -626,7 +643,11 @@ export const NotebookTool = Tool.define("notebook", { }, } } - ctx.metadata({ title: `Python · ${name}`, metadata: { kernel: name, language: "python" } }) + const title = params.title ?? "Python cell" + ctx.metadata({ + title, + metadata: { kernel: name, language: "python", task: title, ...(params.source ? { source: params.source } : {}) }, + }) // Executes arbitrary code — same permission gate as bash. await ctx.ask({ @@ -639,7 +660,7 @@ export const NotebookTool = Tool.define("notebook", { const result = await KernelRuntime.execute(identity, params.code!, { timeout: params.timeout, signal: ctx.abort, - origin: { messageID: ctx.messageID, callID: ctx.callID }, + origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, }) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) @@ -660,12 +681,20 @@ export const NotebookTool = Tool.define("notebook", { const output = clip(parts.join("\n")) ctx.metadata({ - title: `Python · ${name}`, - metadata: { output, ok: result.ok, provenanceID: result.provenanceID, kernel: name, language: "python" }, + title, + metadata: { + output, + ok: result.ok, + provenanceID: result.provenanceID, + kernel: name, + language: "python", + task: title, + ...(params.source ? { source: params.source } : {}), + }, }) return { - title: result.ok ? `Python · ${name}` : `Python · ${name} (error)`, + title: result.ok ? title : `${title} (error)`, output, metadata: { stopped: false, @@ -673,6 +702,8 @@ export const NotebookTool = Tool.define("notebook", { output, kernel: name, language: "python", + task: title, + ...(params.source ? { source: params.source } : {}), provenanceID: result.provenanceID, executionCount: result.executionCount, hasImages: images.length, diff --git a/backend/cli/src/tool/rkernel.ts b/backend/cli/src/tool/rkernel.ts index 24210a8e..bf845640 100644 --- a/backend/cli/src/tool/rkernel.ts +++ b/backend/cli/src/tool/rkernel.ts @@ -333,6 +333,7 @@ class RKernel implements Kernel { private async run(code: string, opts?: ExecuteOptions): Promise { if (!this.ready) throw new Error("R kernel is not running") + opts?.onStart?.() const proc = this.proc! this.lastUsed = Date.now() const timeout = Math.min(Math.max(opts?.timeout ?? 120_000, 5_000), 600_000) @@ -519,6 +520,8 @@ export const RKernelTool = Tool.define("rkernel", { description: [ "Execute R code in a persistent, managed kernel. Objects, attached packages, and state persist across calls that use the same kernel name.", "For multiple independent analyses, issue multiple kernel calls in the same response with distinct `kernel` names. Those kernels execute concurrently and appear separately in Compute.", + "Always set `title` to a concise description of the scientific action, not a code fragment or import.", + "Set `source` when the cell belongs to a script or .ipynb file so Compute can identify that source.", "Never use shell subprocesses to imitate multiple kernels; use this tool's `kernel` parameter instead.", "After a named analysis is fully saved and verified, call this tool with `action: stop` and the same kernel name so completed workers do not idle.", "Use instead of `bash Rscript` for analysis — no need to re-source data or reload packages between cells.", @@ -529,6 +532,20 @@ export const RKernelTool = Tool.define("rkernel", { .object({ action: z.enum(["execute", "stop"]).optional().describe("Execute a cell (default) or stop this named kernel"), code: z.string().optional().describe("R code to execute; required when action is execute"), + title: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe("Short action label for this cell, for example 'Comparing survival curves'"), + source: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe("Script or notebook path this cell belongs to, when applicable"), kernel: z .string() .trim() @@ -568,7 +585,11 @@ export const RKernelTool = Tool.define("rkernel", { }, } } - ctx.metadata({ title: `R · ${name}`, metadata: { kernel: name, language: "r" } }) + const title = params.title ?? "R cell" + ctx.metadata({ + title, + metadata: { kernel: name, language: "r", task: title, ...(params.source ? { source: params.source } : {}) }, + }) // Executes arbitrary code — same permission gate as bash. await ctx.ask({ @@ -594,7 +615,7 @@ export const RKernelTool = Tool.define("rkernel", { const result = await KernelRuntime.execute(identity, params.code!, { timeout: params.timeout, signal: ctx.abort, - origin: { messageID: ctx.messageID, callID: ctx.callID }, + origin: { messageID: ctx.messageID, callID: ctx.callID, title, source: params.source }, }) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) @@ -608,12 +629,20 @@ export const RKernelTool = Tool.define("rkernel", { const output = clip(parts.join("\n")) ctx.metadata({ - title: `R · ${name}`, - metadata: { output, ok: result.ok, provenanceID: result.provenanceID, kernel: name, language: "r" }, + title, + metadata: { + output, + ok: result.ok, + provenanceID: result.provenanceID, + kernel: name, + language: "r", + task: title, + ...(params.source ? { source: params.source } : {}), + }, }) return { - title: result.ok ? `R · ${name}` : `R · ${name} (error)`, + title: result.ok ? title : `${title} (error)`, output, metadata: { stopped: false, @@ -622,6 +651,8 @@ export const RKernelTool = Tool.define("rkernel", { output, kernel: name, language: "r", + task: title, + ...(params.source ? { source: params.source } : {}), provenanceID: result.provenanceID, hasImages: images.length, ...(images.length ? { artifact: { kind: "image", data: { images: dataUrls } } } : {}), diff --git a/backend/cli/test/provider/provider.test.ts b/backend/cli/test/provider/provider.test.ts index 74e447f0..c84c5a97 100644 --- a/backend/cli/test/provider/provider.test.ts +++ b/backend/cli/test/provider/provider.test.ts @@ -93,7 +93,7 @@ test("Codex OAuth allowlist includes the GPT-5.6 family", () => { } }) -test("synthesized Codex OAuth models use Codex variants and context instead of public API metadata", async () => { +test("synthesized Codex OAuth models use Codex variants and preserve model-specific context", async () => { const previous = await Auth.get("openai-codex") await using tmp = await tmpdir({ config: { @@ -123,19 +123,25 @@ test("synthesized Codex OAuth models use Codex variants and context instead of p const sol = codex.models["gpt-5.6-sol"] expect(sol.providerID).toBe("openai-codex") - expect(sol.limit.context).toBe(272_000) + expect(sol.limit.context).toBe(1_050_000) expect(sol.cost).toEqual({ input: 0, output: 0, cache: { read: 0, write: 0 } }) expect(Object.keys(sol.variants ?? {})).toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]) expect(Object.keys(sol.modes ?? {})).toEqual(["fast"]) expect(sol.modes?.fast.provider?.body).toEqual({ service_tier: "priority" }) const codex54 = codex.models["gpt-5.4"] + expect(codex54.limit.context).toBe(1_050_000) expect(Object.keys(codex54.variants ?? {})).toEqual(["low", "medium", "high", "xhigh"]) expect(Object.keys(codex54.modes ?? {})).toEqual(["fast"]) - expect(Object.keys(codex.models["gpt-5.4-mini"].modes ?? {})).toEqual(["fast"]) + const mini = codex.models["gpt-5.4-mini"] + expect(mini.limit.context).toBe(400_000) + expect(Object.keys(mini.modes ?? {})).toEqual(["fast"]) expect(codex.name).toBe("OpenAI (Codex subscription)") const publicSol = providers.openai?.models["gpt-5.6-sol"] + for (const [id, model] of Object.entries(codex.models)) { + expect(model.limit.context).toBe(providers.openai?.models[id]?.limit.context) + } expect(Object.keys(publicSol?.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh", "max"]) }, }) diff --git a/backend/cli/test/server/notebook.test.ts b/backend/cli/test/server/notebook.test.ts index 7e268348..ec75bc61 100644 --- a/backend/cli/test/server/notebook.test.ts +++ b/backend/cli/test/server/notebook.test.ts @@ -450,7 +450,8 @@ describe("/notebook routes", () => { return waitForKernel(attempt + 1) } await waitForKernel() - const second = execute("queue_value.append('second') or queue_value") + const secondCode = "(__import__('time').sleep(0.4), queue_value.append('second'), queue_value)[-1]" + const second = execute(secondCode) await Bun.sleep(20) const status = await app.request( `/status?sessionID=${encodeURIComponent(session.id)}&id=analysis.ipynb&language=python`, @@ -458,8 +459,29 @@ describe("/notebook routes", () => { expect(await status.json()).toMatchObject({ state: "running", queue_depth: 1, + last_cell: { + source: "analysis.ipynb", + code: "(__import__('time').sleep(0.5), globals().__setitem__('queue_value', ['first']), 'first')[-1]", + status: "running", + execution_count: 1, + }, + }) + const firstResponse = await first + await Bun.sleep(20) + const secondStatus = await app.request( + `/status?sessionID=${encodeURIComponent(session.id)}&id=analysis.ipynb&language=python`, + ) + expect(await secondStatus.json()).toMatchObject({ + state: "running", + queue_depth: 0, + last_cell: { + source: "analysis.ipynb", + code: secondCode, + status: "running", + execution_count: 2, + }, }) - const [firstResponse, secondResponse] = await Promise.all([first, second]) + const secondResponse = await second const firstResult = (await firstResponse.json()) as { execution_count: number outputs: Array<{ data?: Record }> diff --git a/docs/notes/claude-science-ui-behavior-audit.md b/docs/notes/claude-science-ui-behavior-audit.md index a9e828b2..2941f273 100644 --- a/docs/notes/claude-science-ui-behavior-audit.md +++ b/docs/notes/claude-science-ui-behavior-audit.md @@ -1,6 +1,6 @@ # Claude Science UI behavior audit -Observed on 2026-08-08 against these local reference surfaces: +Observed on 2026-08-09 against these local reference surfaces: - Project shell: `http://localhost:8765/projects/proj_41efbe3a56fc` - Completed four-kernel reference: `http://localhost:8765/projects/proj_41efbe3a56fc/frames/4654d750-f605-4d90-a749-0d6a9ecf2615` @@ -11,13 +11,13 @@ This is a behavior index, not a request to reproduce Claude branding. It records ## 1. Project shell and navigation -| Surface | Claude Science behavior | OpenScience contract | -| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| Project identity | Back control and project name anchor the shell. | Keep project identity at the top of the sessions rail. | -| Primary navigation | New, Search, Customize, Files, and Compute use one compact type scale and icon rhythm. | Use the same typography as the sessions rail for every primary item, including Customize and settings content. | -| Session list | Sessions are grouped by time, have a readable activity state, and expose row actions without taking over the row. | Preserve session titles and activity dots; keep utility controls visually secondary. | -| Open work | Multiple sessions remain open as tabs. | Session tabs may change while the right inspector remains project-scoped and mounted. | -| Density | Dividers, labels, and counters are quiet; content carries the emphasis. | Avoid oversized settings type, heavy borders, or card-on-card decoration. | +| Surface | Claude Science behavior | OpenScience contract | +| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Project identity | Back control and project name anchor the shell. | Keep project identity at the top of the sessions rail. | +| Primary navigation | New, Search, Customize, Files, and Compute use one compact type scale and icon rhythm. | Use the same 12px/400 typography as the sessions rail for settings navigation; reserve 500 only for the active item. | +| Session list | Sessions are grouped by time, have a readable activity state, and expose row actions without taking over the row. | Preserve session titles and activity dots; keep utility controls visually secondary. | +| Open work | Multiple sessions remain open as tabs. | Session tabs may change while the right inspector remains project-scoped and mounted. | +| Density | Dividers, labels, and counters are quiet; content carries the emphasis. | Avoid oversized settings type, heavy borders, or card-on-card decoration. | ## 2. Right-side workspace @@ -30,24 +30,25 @@ This is a behavior index, not a request to reproduce Claude branding. It records ## 3. Conversation activity ledger -| Surface | Claude Science behavior | OpenScience contract | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Progress grouping | Steps are grouped into summaries such as “Ran 3 commands” or “Saved artifacts.” | Routine reasoning may remain in Show steps, but scientific code, outputs, figures, artifacts, and remote results are promoted outside it. | -| Step labels | Each operation has a task label and a compact result summary such as lines of output, figure count, or artifact count. | Tool headers state language, kernel, state, and useful output identity. | -| Live state | Background cells visibly move through queued/running/finished/failed states. | Named kernels, commands, and remote jobs poll into Compute while the turn is running. | -| Failures | Failures stay visible and are followed by a short diagnosis and retry. | Preserve failed code/output in chat; retries appear as later cards rather than replacing history. | -| Narrative checkpoints | The agent explains handoffs: kernels ready, a plot needs correction, outputs are being saved. | Final answers summarize the completed tracks, key metrics, artifact location, and cleanup state. | +| Surface | Claude Science behavior | OpenScience contract | +| --------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Progress grouping | Steps are grouped into summaries such as “Ran 3 commands” or “Saved artifacts.” | Routine reasoning may remain in Show steps, but scientific code, outputs, figures, artifacts, and remote results are promoted outside it. | +| Step labels | Each operation has an action label (“Benchmarking classifiers”), not its first source line or import. | Notebook/R calls carry a concise task title and optional script/notebook source; older calls get conservative inferred labels. | +| Live state | Background cells visibly move through queued/running/finished/failed states. | Named kernels, commands, and remote jobs poll into Compute while the turn is running. | +| Failures | Failures remain as compact receipts under the step group; successful retries are promoted into the main result flow. | Keep failed cells collapsed in Show steps, never promote raw traces, and retain later successful retries as separate results. | +| Narrative checkpoints | The agent explains handoffs: kernels ready, a plot needs correction, outputs are being saved. | Final answers summarize the completed tracks, key metrics, artifact location, and cleanup state. | ## 4. Code and output cards -| Surface | Claude Science behavior | OpenScience contract | -| -------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Default presentation | A step opens into a language/environment header, source, and separate output control. | Notebook, artifact, and remote-compute cards start collapsed so completed work stays compact and expands on demand. | -| Source height | Long source remains contained inside the operation instead of dominating the transcript. | Show exactly five code lines in a vertically and horizontally scrollable source window; retain the complete source in that window. | -| Output | Text output is visually separated from source. | Text output is open by default and independently scrollable. | -| Figures | Figures appear immediately after the cell that produced them. | Inline notebook images stay visible even when text output is collapsed. | -| Identity | The environment name is always visible. | Show the stable named kernel (`env titanic-quality`, etc.) in each card. | -| Completion | Stopped workers get a compact lifecycle receipt. | Render `Kernel stopped` cards and keep the source/results above them. | +| Surface | Claude Science behavior | OpenScience contract | +| -------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Default presentation | A step opens into a language/environment header, source, and separate output control. | Notebook, artifact, and remote-compute cards start collapsed so completed work stays compact and expands on demand. | +| Source height | Long source remains contained inside the operation instead of dominating the transcript. | Show exactly five code lines in a vertically and horizontally scrollable source window; retain the complete source in that window. | +| Output | Text output is visually separated from source. | Text output is open by default and independently scrollable. | +| Figures | Figures appear immediately after the cell that produced them. | Inline notebook images stay visible even when text output is collapsed. | +| Identity | The environment name is always visible. | Show the stable named kernel (`env titanic-quality`, etc.) in each card. | +| Completion | Stopped workers get a compact lifecycle receipt. | Render `Kernel stopped` cards and keep the source/results above them. | +| Failures | Failed cells are collapsed receipts, with the diagnosis/retry represented by later steps. | Keep failure detail available under Show steps while promoting only successful scientific results. | ## 5. Compute @@ -55,22 +56,23 @@ This is a behavior index, not a request to reproduce Claude branding. It records | --------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Host strip | Memory, CPU, live-kernel count, and running count are always visible. | Host totals combine local kernels, shell commands, and remote jobs. Unknown metrics render as unavailable, never fabricated zeroes. | | Project ledger | Work is grouped by owning session with a current-session marker. | Compute aggregates every session in the project and does not reset when the selected session changes. | -| Kernel row | Language, state, age/cell count, activity label, RSS, CPU, and stop action form one dense row. | Live rows show named kernel, state/recovery text, uptime, RSS, cores, and Stop. | +| Kernel row | Language, state, age/cell count, activity label, RSS, CPU, and stop action form one dense row. | Live rows show named kernel, state/recovery text, uptime, RSS, cores, Stop, and a collapsed latest-cell inspector with source and full code. | | Job row | Long-running/background work stays visible independently of chat scroll. | Shell commands and Modal/GPU jobs are first-class rows with command/target, resources, duration, status, output, artifacts, cleanup, and cancel. | -| Completed work | Claude commonly leaves idle kernels visible. | OpenScience removes completed, stopped, and killed local kernels from Compute; their source, results, and artifacts remain in chat and Files. | +| Completed work | The completed reference has an empty Compute surface after its workers finish. | Compute is live-only: completed, failed, cancelled, stopped, and killed work disappears; durable outputs remain in chat and Files. | | Manual creation | Claude exposes environment setup as part of agent work, not a user kernel launcher in the completed session. | Do not expose manual kernel creation. Kernels start only when an agent executes work. | | Cleanup | Claude exposes stop/kill per kernel but may leave kernels idle. | The research agent must stop every named kernel after outputs and artifacts are verified. Remote cleanup warnings remain visible. | ## 6. Files and artifacts -| Surface | Claude Science behavior | OpenScience contract | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| Automatic collection | Generated files appear without a Browse step. | `save_file` promotes files into the project artifact store automatically. | -| Grouping | Artifacts are grouped by session and show a count and relative time. | Files is project-wide, groups by session, and marks artifacts created by the active run. | -| Grid/list | Images get thumbnails; CSV cards show dimensions/schema; reports show rendered content; grid and list layouts are available. | Figures preview inline, text/Markdown renders in chat, and existing Files previews retain grid/list support. | -| Actions | Open in split, download, and more-actions controls are adjacent to each artifact. | Chat provides `Open beside chat`; Files owns project-level artifact actions. | -| Naming | Artifacts use meaningful names and the final answer links the consolidated report. | Blank summaries fall back to filename; the agent is prompted to provide descriptive non-empty titles. | -| Versioning | Saved outputs are durable products of the session. | Artifact cards show kind, version, size, and checksum; overwrites create durable versions. | +| Surface | Claude Science behavior | OpenScience contract | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Automatic collection | Generated files appear without a Browse step. | `save_file` promotes files into the project artifact store automatically. | +| Grouping | Artifacts are grouped by session and show a count and relative time. | Files is project-wide, groups by session, and marks artifacts created by the active run. | +| Grid/list | Images get thumbnails; CSV cards show dimensions/schema; reports show rendered content; grid and list layouts are available. | Figures preview inline, text/Markdown renders in chat, and existing Files previews retain grid/list support. | +| Actions | Open in split, download, and more-actions controls are adjacent to each artifact. | Chat provides `Open beside chat`; Files owns project-level artifact actions. | +| Naming | Artifacts use meaningful names and the final answer links the consolidated report. | Blank summaries fall back to filename; the agent is prompted to provide descriptive non-empty titles. | +| Versioning | Saved outputs are durable products of the session. | Artifact cards show kind, version, size, and checksum; overwrites create durable versions. | +| Generated strip | A `GENERATED · N` strip closes the turn; clicking a card opens that durable artifact in the Files surface beside chat. | Render one end-of-turn Generated strip from completed saved-artifact receipts and open the durable artifact record, not the scratch path. | ## 7. Scientific result quality @@ -99,6 +101,7 @@ OpenScience's research prompt now requires at least two decision-useful figures ## 9. Intentional OpenScience differences - Finished kernels are stopped automatically rather than left idle, and disappear from Compute once they are no longer live. +- Completed remote jobs also leave Compute; their receipts and durable outputs remain in chat and Files. - Manual kernel creation is removed. The execution ledger describes real work; it is not a launcher. - Compute also includes shell subprocesses and Modal/GPU jobs, which the reference surface did not expose in this exact local run. - Project-wide Files and Compute remain stable while sessions switch, matching the requested cross-session workspace model. @@ -112,9 +115,12 @@ OpenScience's research prompt now requires at least two decision-useful figures - [x] Figures display inline beside their producing cells. - [x] Saved report, tables, and figures auto-appear in Files. - [x] Artifact titles are meaningful and previews open beside chat. -- [x] Failed analysis is visible and can be retried without losing history. +- [x] A `Generated · N` strip opens durable artifact versions beside chat. +- [x] Failed analysis stays as a compact step receipt and can be retried without losing history. +- [x] Cell labels describe the scientific action instead of displaying an import or first code line. +- [x] Live kernel rows expose the latest cell title, script/notebook source, and a collapsed five-line code viewport. - [x] Every named kernel stops after result verification. - [x] Completed, stopped, and killed local kernels disappear from Compute. -- [x] Modal/GPU jobs have live and recent-result rows with resources, logs, artifacts, cancel, and cleanup state. +- [x] Modal/GPU jobs have live rows with resources, logs, artifacts, cancel, and cleanup state; completed jobs leave Compute. - [x] The right workspace remains project-scoped across session changes. - [x] Compute and artifact cards adapt at narrow container widths. diff --git a/frontend/ui/src/components/message-part.tsx b/frontend/ui/src/components/message-part.tsx index 3b655eeb..a8042cd1 100644 --- a/frontend/ui/src/components/message-part.tsx +++ b/frontend/ui/src/components/message-part.tsx @@ -52,7 +52,7 @@ import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" import { createResizeObserver } from "@solid-primitives/resize-observer" import { NotebookView, type NotebookCellProps } from "./notebook-cell" -import { skillName, stripRedactedReasoning } from "./tool-display" +import { savedArtifact, scienceTaskLabel, skillName, stripRedactedReasoning } from "./tool-display" import { ToolRegistry, type ToolProps } from "./tool-registry" export { ARTIFACT_TOOL, ToolRegistry, type ToolComponent, type ToolProps } from "./tool-registry" @@ -807,7 +807,14 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { function KernelTool(props: ToolProps & { language: "python" | "r"; label: "Python" | "R" }) { const code = () => (typeof props.input.code === "string" ? props.input.code : "") const kernel = () => (typeof props.input.kernel === "string" ? props.input.kernel : props.language) - const preview = () => code().trim().split("\n").find(Boolean)?.slice(0, 120) + const task = () => scienceTaskLabel({ title: props.input.title, code: code(), language: props.language }) + const source = () => (typeof props.input.source === "string" ? props.input.source : undefined) + const count = () => (typeof props.metadata.executionCount === "number" ? props.metadata.executionCount : undefined) + const failed = () => props.metadata.ok === false || props.status === "error" + const subtitle = () => + [props.label, `env ${kernel()}`, count() === undefined ? undefined : `cell ${count()}`, source()] + .filter(Boolean) + .join(" · ") const images = () => { const artifact = props.metadata.artifact if (!artifact || typeof artifact !== "object") return [] @@ -832,14 +839,14 @@ function KernelTool(props: ToolProps & { language: "python" | "r"; label: "Pytho {...props} icon="code" trigger={{ - title: props.status === "completed" ? "Computed" : "Computing", - subtitle: preview(), + title: failed() ? `Failed · ${task()}` : props.status === "completed" ? task() : `Running · ${task()}`, + subtitle: subtitle(), }} >
{props.label} - env {kernel()} + {subtitle()}
           {code()}
@@ -874,32 +881,15 @@ ToolRegistry.register({
 
 function SavedArtifactTool(props: ToolProps) {
   const data = useData()
-  const saved = () => {
-    const value = props.metadata.savedArtifact
-    if (!value || typeof value !== "object") return
-    if (
-      typeof value.title !== "string" ||
-      typeof value.kind !== "string" ||
-      typeof value.path !== "string" ||
-      typeof value.id !== "string" ||
-      typeof value.versionID !== "string" ||
-      typeof value.version !== "number" ||
-      typeof value.size !== "number" ||
-      typeof value.sha256 !== "string"
-    )
+  const saved = () => savedArtifact(props.metadata.savedArtifact)
+  const open = () => {
+    const artifact = saved()
+    if (!artifact) return
+    if (data.openArtifact) {
+      data.openArtifact(artifact.id)
       return
-    return value as {
-      title: string
-      kind: string
-      path: string
-      id: string
-      versionID: string
-      mimeType?: string
-      version: number
-      size: number
-      sha256: string
-      preview?: { kind: "image" | "text"; data: string }
     }
+    data.openFile?.(artifact.path)
   }
 
   return (
@@ -945,7 +935,7 @@ function SavedArtifactTool(props: ToolProps) {
               )}
             
             
- {artifact().size.toLocaleString()} bytes diff --git a/frontend/ui/src/components/session-turn-science-results.test.ts b/frontend/ui/src/components/session-turn-science-results.test.ts index a7aaa63b..953a13de 100644 --- a/frontend/ui/src/components/session-turn-science-results.test.ts +++ b/frontend/ui/src/components/session-turn-science-results.test.ts @@ -4,10 +4,19 @@ import { fileURLToPath } from "node:url" const source = readFileSync(fileURLToPath(new URL("./session-turn.tsx", import.meta.url)), "utf8") -test("scientific code, results, artifacts, and remote jobs stay outside collapsed steps", () => { - expect(source).toContain('new Set(["notebook", "rkernel", "artifact", "modal", "compute_job"])') +test("successful science results stay outside collapsed steps while failures remain inspectable", () => { + expect(source).toContain('new Set(["notebook", "rkernel", "modal", "compute_job"])') expect(source).toContain('aria-label="Analysis code and results"') expect(source).toContain("hidePromotedTools") expect(source).toContain(".filter(isPromotedTool)") - expect(source).toContain("parts.filter((part) => !isPromotedTool(part))") + expect(source).toContain("parts.filter((part) => !isHiddenTool(part))") + expect(source).toContain('part.state.status === "error"') + expect(source).toContain("metadata?.ok !== false") +}) + +test("completed saved artifacts render in an end-of-turn Generated strip", () => { + expect(source).toContain("generatedArtifacts(") + expect(source).toContain('data-slot="session-turn-generated"') + expect(source).toContain('data-slot="session-turn-generated-artifact"') + expect(source).toContain("data.openArtifact(artifact.id)") }) diff --git a/frontend/ui/src/components/session-turn.css b/frontend/ui/src/components/session-turn.css index b9b9168f..f01e5adf 100644 --- a/frontend/ui/src/components/session-turn.css +++ b/frontend/ui/src/components/session-turn.css @@ -606,6 +606,107 @@ gap: 12px; } + [data-slot="session-turn-generated"] { + width: 100%; + min-width: 0; + display: grid; + gap: 7px; + padding-top: 2px; + + > header { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-weak); + font-size: 11px; + line-height: 16px; + text-transform: uppercase; + letter-spacing: 0.035em; + + strong { + color: var(--text-base); + font-weight: 500; + } + } + } + + [data-slot="session-turn-generated-list"] { + display: flex; + min-width: 0; + gap: 7px; + padding-bottom: 3px; + overflow-x: auto; + scrollbar-width: thin; + scroll-snap-type: x proximity; + } + + [data-slot="session-turn-generated-artifact"] { + width: 154px; + min-width: 154px; + display: grid; + grid-template-rows: 86px auto; + padding: 0; + overflow: hidden; + border: 1px solid var(--border-weak-base); + border-radius: 8px; + background: var(--background-base); + color: var(--text-base); + font: inherit; + text-align: left; + cursor: pointer; + scroll-snap-align: start; + + &:hover { + border-color: var(--border-base); + background: var(--surface-raised-base, var(--background-strong)); + } + + &:focus-visible { + outline: 2px solid var(--border-focus-base, var(--text-interactive-base)); + outline-offset: 2px; + } + } + + [data-slot="session-turn-generated-preview"] { + display: grid; + place-items: center; + overflow: hidden; + border-bottom: 1px solid var(--border-weak-base); + background: var(--background-strong); + color: var(--icon-weak-base); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + [data-slot="session-turn-generated-copy"] { + min-width: 0; + display: grid; + gap: 1px; + padding: 7px 8px 8px; + + strong, + small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + font-size: 12px; + font-weight: 500; + } + + small { + color: var(--text-weak); + font-size: 10px; + text-transform: capitalize; + } + } + [data-slot="session-turn-artifact-save"] { width: 100%; min-width: 0; diff --git a/frontend/ui/src/components/session-turn.tsx b/frontend/ui/src/components/session-turn.tsx index 8a6f1346..fe56b5d6 100644 --- a/frontend/ui/src/components/session-turn.tsx +++ b/frontend/ui/src/components/session-turn.tsx @@ -19,7 +19,7 @@ import { Binary } from "@synsci/util/binary" import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" import { Message, Part } from "./message-part" -import { artifactActions, stripRedactedReasoning, writtenFiles } from "./tool-display" +import { artifactActions, generatedArtifacts, stripRedactedReasoning, writtenFiles } from "./tool-display" import { Markdown } from "./markdown" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" @@ -96,10 +96,22 @@ function isAttachment(part: PartType | undefined) { ) } -const promotedTools = new Set(["notebook", "rkernel", "artifact", "modal", "compute_job"]) +const promotedTools = new Set(["notebook", "rkernel", "modal", "compute_job"]) function isPromotedTool(part: PartType | undefined): part is ToolPart { - return part?.type === "tool" && promotedTools.has(part.tool) + if (part?.type !== "tool" || !promotedTools.has(part.tool)) return false + if (part.state.status === "error") return false + if (part.state.status !== "running" && part.state.status !== "completed") return false + const metadata = "metadata" in part.state ? (part.state.metadata as Record | undefined) : undefined + return metadata?.ok !== false +} + +function isGeneratedTool(part: PartType | undefined): part is ToolPart { + return part?.type === "tool" && part.tool === "artifact" && part.state.status === "completed" +} + +function isHiddenTool(part: PartType | undefined): part is ToolPart { + return isPromotedTool(part) || isGeneratedTool(part) } function AssistantMessageItem(props: { @@ -129,7 +141,7 @@ function AssistantMessageItem(props: { } if (props.hidePromotedTools) { - parts = parts.filter((part) => !isPromotedTool(part)) + parts = parts.filter((part) => !isHiddenTool(part)) } if (!props.hideResponsePart) return parts @@ -289,6 +301,10 @@ export function SessionTurn( }), ) + const generated = createMemo(() => + generatedArtifacts(assistantMessages().flatMap((message) => data.store.part[message.id] ?? emptyParts)), + ) + const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions) const nextPermission = createMemo(() => permissions()[0]) const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions) @@ -829,6 +845,48 @@ export function SessionTurn(
+ 0}> +
+
+ Generated + · {generated().length} +
+
+ + {(artifact) => ( + + )} + +
+
+
{/* Explicit save: offer the written files as durable versioned artifacts */} 0}>
diff --git a/frontend/ui/src/components/tool-display.test.ts b/frontend/ui/src/components/tool-display.test.ts index d1751380..d6a409ce 100644 --- a/frontend/ui/src/components/tool-display.test.ts +++ b/frontend/ui/src/components/tool-display.test.ts @@ -1,5 +1,14 @@ import { describe, test, expect } from "bun:test" -import { artifactActions, humanizeToolName, skillName, stripRedactedReasoning, writtenFiles } from "./tool-display" +import { + artifactActions, + generatedArtifacts, + humanizeToolName, + savedArtifact, + scienceTaskLabel, + skillName, + stripRedactedReasoning, + writtenFiles, +} from "./tool-display" describe("humanizeToolName", () => { test("titlecases a simple id", () => { @@ -109,3 +118,51 @@ describe("stripRedactedReasoning", () => { expect(stripRedactedReasoning("plain reasoning text")).toBe("plain reasoning text") }) }) + +describe("scienceTaskLabel", () => { + test("prefers an explicit action title", () => { + expect(scienceTaskLabel({ title: "Benchmarking survival classifiers.", code: "from pathlib import Path" })).toBe( + "Benchmarking survival classifiers", + ) + }) + + test("never uses an import as the visible label", () => { + expect(scienceTaskLabel({ code: "from pathlib import Path\nimport pandas as pd", language: "python" })).toBe( + "Python cell", + ) + }) + + test("derives conservative labels for older scientific calls", () => { + expect(scienceTaskLabel({ code: "df = pd.read_csv('data/titanic.csv')" })).toBe("Loading titanic.csv") + expect(scienceTaskLabel({ code: "model = LogisticRegression().fit(X, y)" })).toBe("Fitting statistical models") + expect(scienceTaskLabel({ code: "plt.plot(x, y)\nplt.savefig('figures/roc.png')" })).toBe("Rendering roc.png") + }) +}) + +describe("generatedArtifacts", () => { + const artifact = { + title: "ROC curve", + kind: "figure", + path: "figures/roc.png", + id: "art_1", + versionID: "ver_1", + version: 1, + size: 42, + sha256: "abc123", + preview: { kind: "image" as const, data: "data:image/png;base64,abc" }, + } + + test("normalizes saved artifact metadata", () => { + expect(savedArtifact(artifact)).toEqual(artifact) + }) + + test("collects only completed artifact versions and deduplicates them", () => { + expect( + generatedArtifacts([ + { type: "tool", tool: "artifact", state: { status: "completed", metadata: { savedArtifact: artifact } } }, + { type: "tool", tool: "artifact", state: { status: "completed", metadata: { savedArtifact: artifact } } }, + { type: "tool", tool: "artifact", state: { status: "error", metadata: { savedArtifact: artifact } } }, + ]), + ).toEqual([artifact]) + }) +}) diff --git a/frontend/ui/src/components/tool-display.ts b/frontend/ui/src/components/tool-display.ts index 2da9347a..2d18d986 100644 --- a/frontend/ui/src/components/tool-display.ts +++ b/frontend/ui/src/components/tool-display.ts @@ -20,6 +20,114 @@ export function stripRedactedReasoning(text: string): string { return (text ?? "").replaceAll("[REDACTED]", "").trim() } +export type SavedArtifact = { + title: string + kind: string + path: string + id: string + versionID: string + mimeType?: string + version: number + size: number + sha256: string + preview?: { kind: "image" | "text"; data: string } +} + +const record = (value: unknown): Record | undefined => { + if (!value || typeof value !== "object" || Array.isArray(value)) return + return value as Record +} + +export function savedArtifact(value: unknown): SavedArtifact | undefined { + const item = record(value) + if ( + !item || + typeof item.title !== "string" || + typeof item.kind !== "string" || + typeof item.path !== "string" || + typeof item.id !== "string" || + typeof item.versionID !== "string" || + typeof item.version !== "number" || + typeof item.size !== "number" || + typeof item.sha256 !== "string" + ) + return + const raw = record(item.preview) + const kind = raw?.kind + const preview: SavedArtifact["preview"] = + raw && (kind === "image" || kind === "text") && typeof raw.data === "string" ? { kind, data: raw.data } : undefined + return { + title: item.title, + kind: item.kind, + path: item.path, + id: item.id, + versionID: item.versionID, + ...(typeof item.mimeType === "string" ? { mimeType: item.mimeType } : {}), + version: item.version, + size: item.size, + sha256: item.sha256, + ...(preview ? { preview } : {}), + } +} + +export function generatedArtifacts( + parts: ReadonlyArray<{ + type: string + tool?: string + state?: { status?: string; metadata?: unknown } + }>, +): SavedArtifact[] { + const seen = new Set() + return parts.flatMap((part) => { + if (part.type !== "tool" || part.tool !== "artifact" || part.state?.status !== "completed") return [] + const metadata = record(part.state.metadata) + const artifact = savedArtifact(metadata?.savedArtifact) + if (!artifact || seen.has(artifact.versionID)) return [] + seen.add(artifact.versionID) + return [artifact] + }) +} + +const filename = (value: string) => value.replaceAll("\\", "/").split("/").pop() || value + +/** + * A stable receipt label for an executed scientific cell. Models can provide a + * concrete action title; older calls fall back to conservative code-shape + * labels instead of leaking an arbitrary first line such as an import. + */ +export function scienceTaskLabel(input: { title?: unknown; code?: unknown; language?: unknown }): string { + if (typeof input.title === "string" && input.title.trim()) + return input.title + .trim() + .replace(/[.\s]+$/, "") + .slice(0, 100) + const code = typeof input.code === "string" ? input.code : "" + const comment = code + .split("\n") + .map((line) => line.trim()) + .find((line) => /^#\s+\S/.test(line) && !/^#\s*(?:coding|type:|noqa|r)/i.test(line)) + if (comment) + return comment + .replace(/^#\s*/, "") + .replace(/[.\s]+$/, "") + .slice(0, 100) + + const read = code.match(/\b(?:read_csv|read_table|read_parquet|read_excel|readRDS|fread)\s*\(\s*[rubf]*["']([^"']+)/i) + const write = code.match( + /\b(?:to_csv|to_parquet|to_excel|savefig|ggsave|write_csv|write\.csv|saveRDS)\s*\(\s*[rubf]*["']([^"']+)/i, + ) + if (/\b(?:savefig|ggsave)\s*\(/i.test(code)) + return write ? `Rendering ${filename(write[1])}` : "Rendering analysis figure" + if (/\b(?:plt\.|sns\.|ggplot\s*\(|plot\s*\()/i.test(code)) return "Rendering analysis figure" + if (/\b(?:cross_val|GridSearch|RandomForest|LogisticRegression|\.fit\s*\(|model\.train\s*\()/i.test(code)) { + return "Fitting statistical models" + } + if (/\b(?:groupby|describe\s*\(|crosstab|summary\s*\(|aggregate\s*\()/i.test(code)) return "Summarizing dataset" + if (read) return `Loading ${filename(read[1])}` + if (write) return `Saving ${filename(write[1])}` + return `${input.language === "r" ? "R" : "Python"} cell` +} + /** * Files a turn actually wrote, from its completed tool parts. write/edit/ * multiedit carry the target in input.filePath; apply_patch lists every diff --git a/frontend/ui/src/context/data.tsx b/frontend/ui/src/context/data.tsx index 3eeea23f..5c18f590 100644 --- a/frontend/ui/src/context/data.tsx +++ b/frontend/ui/src/context/data.tsx @@ -51,6 +51,9 @@ export type NavigateToSessionFn = (sessionID: string) => void /** Explicit save: register a written file as a durable, versioned artifact. */ export type SaveArtifactFn = (path: string) => Promise<{ version: number }> +/** Open a durable saved artifact version in the contextual Files surface. */ +export type OpenArtifactFn = (id: string) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -61,6 +64,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn onOpenFile?: (path: string) => void + onOpenArtifact?: OpenArtifactFn onSaveArtifact?: SaveArtifactFn }) => { return { @@ -75,6 +79,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, openFile: props.onOpenFile, + openArtifact: props.onOpenArtifact, saveArtifact: props.onSaveArtifact, } }, diff --git a/frontend/workspace/src/atlas/ComputeSurface.css b/frontend/workspace/src/atlas/ComputeSurface.css index 51f5cb3a..3db39160 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.css +++ b/frontend/workspace/src/atlas/ComputeSurface.css @@ -308,6 +308,65 @@ outline-offset: 2px; } +.compute-surface .kernel-card__cell { + grid-column: 1 / -1; + min-width: 0; + margin: 0 0 1px 39px; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-bg-subtle); +} + +.compute-surface .kernel-card__cell summary { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 6px 8px; + color: var(--color-text-faint); + font-size: 10px; + cursor: pointer; + list-style-position: inside; +} + +.compute-surface .kernel-card__cell summary strong, +.compute-surface .kernel-card__cell summary small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compute-surface .kernel-card__cell summary strong { + color: var(--color-text); + font-size: 11px; + font-weight: 500; +} + +.compute-surface .kernel-card__cell summary small { + color: var(--color-text-faint); + font-size: 10px; +} + +.compute-surface .kernel-card__cell pre { + max-height: calc(5 * 1.45em + 16px); + margin: 0; + padding: 8px 10px; + overflow: auto; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + font-family: var(--font-mono, monospace); + font-size: 10px; + line-height: 1.45; + white-space: pre; +} + +.compute-surface .kernel-card__cell code { + font: inherit; +} + .compute-surface .remote-results { border-top: 8px solid var(--color-bg-subtle); } diff --git a/frontend/workspace/src/atlas/KernelCard.test.tsx b/frontend/workspace/src/atlas/KernelCard.test.tsx index 57b5ed2e..71869a93 100644 --- a/frontend/workspace/src/atlas/KernelCard.test.tsx +++ b/frontend/workspace/src/atlas/KernelCard.test.tsx @@ -45,6 +45,7 @@ const kernel = (value: Partial = {}): KernelStatus => ({ process_identity_verified: true, started_at: Date.now() - 4_000, last_activity_at: Date.now() - 1_000, + last_cell: null, resources: { cpu_percent: 180, memory_bytes: 412_000_000 }, ...value, }) @@ -106,4 +107,31 @@ describe("kernel status row", () => { await Bun.sleep(1_200) expect(host.querySelector(".kernel-card__uptime")?.textContent).not.toBe(first) }) + + test("keeps the latest executed cell compact and inspectable", () => { + const host = mount(() => + subject.KernelCard({ + kernel: kernel({ + last_cell: { + title: "Benchmarking survival classifiers", + source: "analysis/titanic.ipynb", + code: "model.fit(X, y)", + status: "running", + execution_count: 7, + message_id: "msg_1", + call_id: "call_1", + }, + }), + action: "", + onControl: () => {}, + }), + ) + const cell = host.querySelector(".kernel-card__cell") + + expect(cell?.open).toBe(false) + expect(cell?.querySelector("summary")?.textContent).toContain("Cell 7 · running") + expect(cell?.querySelector("summary")?.textContent).toContain("Benchmarking survival classifiers") + expect(cell?.querySelector("summary")?.textContent).toContain("analysis/titanic.ipynb") + expect(cell?.querySelector("code")?.textContent).toBe("model.fit(X, y)") + }) }) diff --git a/frontend/workspace/src/atlas/KernelCard.tsx b/frontend/workspace/src/atlas/KernelCard.tsx index 41288379..c0468e73 100644 --- a/frontend/workspace/src/atlas/KernelCard.tsx +++ b/frontend/workspace/src/atlas/KernelCard.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" +import { Show, createEffect, createSignal, onCleanup, type JSX } from "solid-js" import { kernelCanStop, kernelLabel, @@ -49,7 +49,11 @@ export function KernelCard(props: { {kernelLabel(props.kernel)}