diff --git a/backend/cli/src/project/trust.ts b/backend/cli/src/project/trust.ts index 4bd22e1a..eecc2fd1 100644 --- a/backend/cli/src/project/trust.ts +++ b/backend/cli/src/project/trust.ts @@ -140,23 +140,23 @@ export namespace ProjectTrust { export async function status(project: Project.Info): Promise { const canonical = root(project) const saved = await record(project) - if (saved?.root === canonical && saved.state === "trusted") { + if (saved?.root !== canonical || saved.state !== "revoked") { return { projectID: project.id, root: canonical, - revision: saved.revision, + revision: saved?.revision ?? 1, state: "trusted", - source: "persisted", + source: saved ? "persisted" : "default", canExecuteProjectCode: true, - time: saved.time, + time: saved?.time, } } return { projectID: project.id, root: canonical, revision: saved?.revision ?? 1, - state: saved?.state === "revoked" ? "revoked" : "untrusted", - source: saved ? "persisted" : "default", + state: "revoked", + source: "persisted", canExecuteProjectCode: false, time: saved?.time, remediation: remediation(project), diff --git a/backend/cli/src/server/routes/notebook.ts b/backend/cli/src/server/routes/notebook.ts index b766ac5c..05733303 100644 --- a/backend/cli/src/server/routes/notebook.ts +++ b/backend/cli/src/server/routes/notebook.ts @@ -55,13 +55,6 @@ const identity = (input: { sessionID: string; id: string; language: Language }): language: input.language, }) -const primary = (sessionID: string): KernelIdentity => ({ - projectID: Instance.project.id, - sessionID, - name: "agent", - language: "python", -}) - const owner = async (c: Context, sessionID: string) => Session.get(sessionID) .then((session) => { @@ -189,12 +182,10 @@ export const NotebookRoutes = lazy(() => const owners = new Set() if (query.sessionID) { owners.add(query.sessionID) - KernelRuntime.ensure(primary(query.sessionID)) } if (!query.sessionID) { for await (const session of Session.list()) { owners.add(session.id) - KernelRuntime.ensure(primary(session.id)) } } const live = KernelRuntime.list(query.sessionID).filter((kernel) => owners.has(kernel.sessionID)) diff --git a/backend/cli/src/server/routes/project.ts b/backend/cli/src/server/routes/project.ts index 63690d7e..aa846666 100644 --- a/backend/cli/src/server/routes/project.ts +++ b/backend/cli/src/server/routes/project.ts @@ -68,7 +68,7 @@ export const ProjectRoutes = lazy(() => describeRoute({ summary: "Inspect project trust", description: - "Inspect whether project-local code may execute. Projects are untrusted by default; read-only project opening remains available.", + "Inspect whether project-local code may execute. Project code is enabled by default and remains disabled only after an explicit revocation.", operationId: "project.trust.get", responses: { 200: { diff --git a/backend/cli/test/project/execution-authority.test.ts b/backend/cli/test/project/execution-authority.test.ts index a4c6a045..54f17396 100644 --- a/backend/cli/test/project/execution-authority.test.ts +++ b/backend/cli/test/project/execution-authority.test.ts @@ -30,7 +30,10 @@ test("session execution authority is inspectable through the project route", asy const project = await Project.fromDirectory(tmp.path) const sessionID = await Instance.provide({ directory: tmp.path, - fn: async () => (await Session.create({})).id, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + return (await Session.create({})).id + }, }) const fetch = Server.internalFetch() const response = await fetch( @@ -58,6 +61,7 @@ test("read-only project authority rejects terminal, shell, and kernel before pro await Instance.provide({ directory: tmp.path, fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) const session = await Session.create({}) const marker = path.join(tmp.path, "process-spawned") const decision = await ExecutionAuthority.decide({ @@ -72,7 +76,7 @@ test("read-only project authority rejects terminal, shell, and kernel before pro mode: "read_only", projectID: Instance.project.id, sessionID: session.id, - trustRevision: 1, + trustRevision: 2, sandbox: { enabled: true, network: "deny", diff --git a/backend/cli/test/project/execution-trust.test.ts b/backend/cli/test/project/execution-trust.test.ts index d9578be8..92a762a0 100644 --- a/backend/cli/test/project/execution-trust.test.ts +++ b/backend/cli/test/project/execution-trust.test.ts @@ -35,6 +35,7 @@ test("built-in project formatter checks trust on every cached file edit", async directory: tmp.path, fn: async () => { try { + await ProjectTrust.update(Instance.project, { trusted: false }) Format.init() await Bus.publish(File.Event.Edited, { file: tmp.extra.file }) expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) @@ -143,6 +144,7 @@ test("built-in project LSP denies, executes when trusted, and stops its cached c directory: tmp.path, fn: async () => { try { + await ProjectTrust.update(Instance.project, { trusted: false }) await LSP.init() await LSP.touchFile(tmp.extra.file) expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) diff --git a/backend/cli/test/project/trust.test.ts b/backend/cli/test/project/trust.test.ts index bbbfe140..8856d6bf 100644 --- a/backend/cli/test/project/trust.test.ts +++ b/backend/cli/test/project/trust.test.ts @@ -27,7 +27,7 @@ description: ${name} trust test skill. ) } -test("untrusted project opens read-only without importing or executing project code", async () => { +test("project code is enabled by default", async () => { await using tmp = await tmpdir({ init: async (dir) => { const local = path.join(dir, ".openscience") @@ -87,20 +87,20 @@ export default async function Probe() { const skills = await Skill.all() const mcps = await MCP.status() - expect(status.state).toBe("untrusted") - expect(status.canExecuteProjectCode).toBe(false) - expect(status.remediation?.body.root).toBe(Instance.project.worktree) + expect(status.state).toBe("trusted") + expect(status.source).toBe("default") + expect(status.canExecuteProjectCode).toBe(true) + expect(status.remediation).toBeUndefined() expect(visible.mcp?.probe).toBeDefined() - expect(executable.mcp?.probe).toBeUndefined() - expect(executable.formatter === false ? undefined : executable.formatter?.probe).toBeUndefined() - expect(executable.lsp === false ? undefined : executable.lsp?.probe).toBeUndefined() - expect(skills.some((item) => item.name === "project-probe")).toBe(false) - expect(mcps.probe).toBeUndefined() + expect(executable.mcp?.probe).toBeDefined() + expect(executable.formatter === false ? undefined : executable.formatter?.probe).toBeDefined() + expect(executable.lsp === false ? undefined : executable.lsp?.probe).toBeDefined() + expect(skills.some((item) => item.name === "project-probe")).toBe(true) + expect(mcps.probe).toBeDefined() }, }) - expect(await Bun.file(tmp.extra).exists()).toBe(false) - expect(await Bun.file(path.join(tmp.path, ".openscience", "node_modules")).exists()).toBe(false) + expect(await Bun.file(tmp.extra).exists()).toBe(true) }) test("trust is canonical, project-isolated, and revocation stops project hooks", async () => { @@ -159,7 +159,8 @@ test("trust is canonical, project-isolated, and revocation stops project hooks", }) expect(alias.state).toBe("trusted") expect(alias.root).toBe(trusted.root) - expect(isolated.state).toBe("untrusted") + expect(isolated.state).toBe("trusted") + expect(isolated.source).toBe("default") expect(isolated.projectID).not.toBe(trusted.projectID) await Instance.disposeAll() @@ -192,7 +193,7 @@ test("trust is canonical, project-isolated, and revocation stops project hooks", expect(await Bun.file(first.extra).exists()).toBe(false) }) -test("user-global plugins and skills remain available in an untrusted project", async () => { +test("user-global and project-local plugins and skills are available by default", async () => { const file = path.join(Global.Path.home, ".claude", "skills", "global-probe", "SKILL.md") const global = path.dirname(file) const plugin = path.join(Global.Path.config, "plugin", "global-probe.ts") @@ -224,7 +225,7 @@ test("user-global plugins and skills remain available in an untrusted project", fn: async () => { const skills = await Skill.all() expect(skills.some((item) => item.name === "global-probe")).toBe(true) - expect(skills.some((item) => item.name === "local-probe")).toBe(false) + expect(skills.some((item) => item.name === "local-probe")).toBe(true) }, }) expect(await Bun.file(marker).text()).toBe("ran") @@ -235,11 +236,12 @@ test("user-global plugins and skills remain available in an untrusted project", } }) -test("denials carry structured remediation without blocking project inspection", async () => { +test("explicit revocation carries structured remediation without blocking project inspection", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) const status = await ProjectTrust.status(Instance.project) expect(await Config.get()).toBeDefined() await expect(ProjectTrust.require(Instance.project, "startup_script")).rejects.toMatchObject({ @@ -253,12 +255,13 @@ test("denials carry structured remediation without blocking project inspection", }) }) -test("untrusted startup scripts fail closed before spawning a shell", async () => { +test("revoked startup scripts fail closed before spawning a shell", async () => { await using tmp = await tmpdir() const marker = path.join(tmp.path, "startup-ran") await Instance.provide({ directory: tmp.path, fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) await Project.update({ projectID: Instance.project.id, commands: { @@ -288,7 +291,7 @@ test("untrusted startup scripts fail closed before spawning a shell", async () = expect(await Bun.file(marker).text()).toBe("startup") }) -test("trust state is inspectable and revocable through the project permission surface", async () => { +test("default trust is inspectable and revocable through the project permission surface", async () => { await using tmp = await tmpdir() const project = await Project.fromDirectory(tmp.path) const fetch = Server.internalFetch() @@ -300,18 +303,11 @@ test("trust state is inspectable and revocable through the project permission su const status = ProjectTrust.Status.parse(await initial.json()) expect(initial.status).toBe(200) - expect(status.state).toBe("untrusted") - - const trusted = await fetch(`http://openscience.internal/project/${project.project.id}/trust`, { - method: "PUT", - headers, - body: JSON.stringify(status.remediation?.body), - }) - expect(trusted.status).toBe(200) - expect(await trusted.json()).toMatchObject({ + expect(status).toMatchObject({ projectID: project.project.id, root: project.project.worktree, state: "trusted", + source: "default", canExecuteProjectCode: true, }) @@ -328,4 +324,16 @@ test("trust state is inspectable and revocable through the project permission su code: "trust_project_required", }, }) + + const disabled = await ProjectTrust.status(project.project) + const trusted = await fetch(`http://openscience.internal/project/${project.project.id}/trust`, { + method: "PUT", + headers, + body: JSON.stringify(disabled.remediation?.body), + }) + expect(trusted.status).toBe(200) + expect(await trusted.json()).toMatchObject({ + state: "trusted", + canExecuteProjectCode: true, + }) }) diff --git a/backend/cli/test/provider/project-trust.test.ts b/backend/cli/test/provider/project-trust.test.ts index d1e830fb..f2a1817e 100644 --- a/backend/cli/test/provider/project-trust.test.ts +++ b/backend/cli/test/provider/project-trust.test.ts @@ -42,6 +42,7 @@ test("untrusted project provider remains readable without importing its file mod await Instance.provide({ directory: tmp.path, fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) const model = await Provider.getModel("probe", "m") expect(model.api.npm.startsWith("file://")).toBe(true) expect(model.api.npm.endsWith("/test/fixture/provider-module.mjs")).toBe(true) diff --git a/backend/cli/test/provider/token-command.test.ts b/backend/cli/test/provider/token-command.test.ts index f2fc78dd..e5b4621c 100644 --- a/backend/cli/test/provider/token-command.test.ts +++ b/backend/cli/test/provider/token-command.test.ts @@ -128,6 +128,7 @@ test("untrusted project tokenCommand cannot spawn", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) const model = await Provider.getModel("token-cmd", "m") const language = await Provider.getLanguage(model) await generateText({ model: language, prompt: "hi" }).catch(() => {}) @@ -147,6 +148,7 @@ test("untrusted project npm provider cannot install or import", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) const model = await Provider.getModel("probe", "m") expect(model.api.npm).toBe("project-provider-probe") await expect(Provider.getLanguage(model)).rejects.toBeInstanceOf(Provider.InitError) diff --git a/backend/cli/test/server/notebook.test.ts b/backend/cli/test/server/notebook.test.ts index 378341fd..710154ee 100644 --- a/backend/cli/test/server/notebook.test.ts +++ b/backend/cli/test/server/notebook.test.ts @@ -77,46 +77,21 @@ describe("/notebook routes", () => { ) }) - test("represents every real session with a lazy default Python record", async () => { + test("does not invent kernels for untouched sessions", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { const app = NotebookRoutes() - const first = await Session.create({}) - const second = await Session.create({}) - const response = await app.request("/kernels") - const result = (await response.json()) as { - kernels: Array<{ - active: boolean - state: string - sessionID: string - name: string - language: string - incarnation: number | null - execution_count: number - process_id: number | null - process_started_at: number | null - }> + const session = await Session.create({}) + const project = (await (await app.request("/kernels")).json()) as { kernels: unknown[] } + const scoped = (await (await app.request(`/kernels?sessionID=${encodeURIComponent(session.id)}`)).json()) as { + kernels: unknown[] } - const defaults = result.kernels.filter((kernel) => kernel.name === "agent") - expect(defaults).toHaveLength(2) - expect(defaults.map((kernel) => kernel.sessionID).sort()).toEqual([first.id, second.id].sort()) - expect(defaults).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - active: false, - state: "lazy", - language: "python", - incarnation: null, - execution_count: 0, - process_id: null, - process_started_at: null, - target: { kind: "local" }, - }), - ]), - ) + expect(project.kernels).toEqual([]) + expect(scoped.kernels).toEqual([]) + expect(KernelRuntime.list()).toEqual([]) }, }) }) @@ -298,14 +273,6 @@ describe("/notebook routes", () => { language: "python", execution_count: 2, }), - expect.objectContaining({ - active: false, - state: "lazy", - sessionID: session.id, - name: "agent", - language: "python", - execution_count: 0, - }), ]), ) @@ -1040,7 +1007,7 @@ describe("/notebook routes", () => { await app.request(`/kernels?sessionID=${encodeURIComponent(session.id)}`) ).json()) as typeof inventory expect(listed.kernels.some((value) => value.id === kernel.id)).toBe(false) - expect(listed.kernels).toContainEqual(expect.objectContaining({ name: "agent", language: "python" })) + expect(listed.kernels).toEqual([]) }, }) }, 30_000) diff --git a/backend/cli/test/server/settings-compute.test.ts b/backend/cli/test/server/settings-compute.test.ts index 1ac66e4e..6e561b90 100644 --- a/backend/cli/test/server/settings-compute.test.ts +++ b/backend/cli/test/server/settings-compute.test.ts @@ -62,6 +62,7 @@ async function session(directory: string, trusted = true) { init: InstanceBootstrap, fn: async () => { if (trusted) return executionSession() + await ProjectTrust.update(Instance.project, { trusted: false }) return Session.create({}) }, }) diff --git a/frontend/workspace/src/artifacts/context.test.ts b/frontend/workspace/src/artifacts/context.test.ts index 1df80fba..04603b17 100644 --- a/frontend/workspace/src/artifacts/context.test.ts +++ b/frontend/workspace/src/artifacts/context.test.ts @@ -92,7 +92,7 @@ describe("artifact context", () => { expect(clearOwnedArtifact(undefined, active.id)).toBeUndefined() }) - test("isolates and restores selected artifacts by project and session", () => { + test("keeps selected artifacts across sessions while isolating projects", () => { const storage = memoryStorage() const first = createArtifactState({ storage }) const alpha = createArtifactContext({ directory: "/alpha", path: "result.csv" }) @@ -100,6 +100,8 @@ describe("artifact context", () => { first.activateScope("project-a", "session-a") first.activate(alpha) + first.activateScope("project-a", "session-b") + expect(first.active()?.id).toBe(alpha.id) first.activateScope("project-b", "session-a") expect(first.active()).toBeUndefined() first.activate(beta) @@ -110,6 +112,6 @@ describe("artifact context", () => { restored.activateScope("project-b", "session-a") expect(restored.active()?.id).toBe(beta.id) restored.activateScope("project-a", "session-b") - expect(restored.active()).toBeUndefined() + expect(restored.active()?.id).toBe(alpha.id) }) }) diff --git a/frontend/workspace/src/artifacts/context.ts b/frontend/workspace/src/artifacts/context.ts index 4fd5b5d4..478fc4a4 100644 --- a/frontend/workspace/src/artifacts/context.ts +++ b/frontend/workspace/src/artifacts/context.ts @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store" import type { ArtifactKind } from "./model" import type { ArtifactInspection } from "@/science/renderers" -import { defaultWorkspaceScope, workspaceScope } from "@/atlas/store/scope" +import { defaultWorkspaceScope, projectScope, workspaceScope } from "@/atlas/store/scope" export type ArtifactContextKind = ArtifactKind | "file" @@ -256,7 +256,13 @@ export function createArtifactState(options: { storage?: ArtifactStorage } = {}) return { scope: () => store.scope, activateScope(project: string, session: string) { - setStore("scope", workspaceScope(project, session)) + const scope = projectScope(project) + const legacy = workspaceScope(project, session) + if (!store.scopes[scope] && store.scopes[legacy]) { + setStore("scopes", scope, store.scopes[legacy]) + persist() + } + setStore("scope", scope) }, active, activate(value: ArtifactContext) { diff --git a/frontend/workspace/src/atlas/ComputeSurface.css b/frontend/workspace/src/atlas/ComputeSurface.css index c3914e7d..18f32603 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.css +++ b/frontend/workspace/src/atlas/ComputeSurface.css @@ -234,13 +234,136 @@ gap: 8px; } +.compute-surface .kernel-panel__sessions { + display: grid; + gap: 16px; +} + +.compute-surface .kernel-session { + display: grid; + gap: 8px; +} + +.compute-surface .kernel-session__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 2px; + color: var(--color-text-muted); + font-size: 11px; +} + +.compute-surface .kernel-session__header > div { + display: flex; + align-items: baseline; + min-width: 0; + gap: 7px; +} + +.compute-surface .kernel-session__header strong { + overflow: hidden; + color: var(--color-text); + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compute-surface .kernel-session[data-current="true"] .kernel-session__header > div > span { + color: var(--color-accent); +} + +.compute-surface .kernel-panel__saved { + display: grid; + gap: 7px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--color-border); +} + +.compute-surface .kernel-panel__saved > header, +.compute-surface .kernel-panel__saved-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.compute-surface .kernel-panel__saved > header { + color: var(--color-text-muted); + font-size: 11px; +} + +.compute-surface .kernel-panel__saved > header strong { + color: var(--color-text); + font-size: 12px; +} + +.compute-surface .kernel-panel__saved-row { + min-height: 42px; + padding: 8px 10px; + border-radius: 9px; + background: var(--color-bg-elevated); + box-shadow: inset 0 0 0 1px var(--color-border); +} + +.compute-surface .kernel-panel__saved-row > div:first-child { + display: grid; + min-width: 0; + gap: 2px; +} + +.compute-surface .kernel-panel__saved-row > div:first-child strong, +.compute-surface .kernel-panel__saved-row > div:first-child span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compute-surface .kernel-panel__saved-row > div:first-child strong { + color: var(--color-text); + font-size: 12px; +} + +.compute-surface .kernel-panel__saved-row > div:first-child span { + color: var(--color-text-muted); + font-size: 10px; +} + +.compute-surface .kernel-panel__saved-row > div:last-child { + display: flex; + gap: 5px; +} + +.compute-surface .kernel-panel__saved-row button { + min-height: 26px; + padding: 0 8px; + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text-muted); + background: transparent; + font-size: 10px; + cursor: pointer; +} + +.compute-surface .kernel-panel__saved-row button:hover:not(:disabled) { + color: var(--color-text); + background: var(--color-bg-hover); +} + +.compute-surface .kernel-panel__saved-row button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + /* 3a nests the kernel in its own card rather than letting it sit flat on the panel — the runtime is a distinct object with its own controls, and the inset says so. The card itself carries no padding: the head owns its own, so the divider under it can run the full width of the plate. */ .compute-surface .kernel-card { gap: 0; - margin: 0 0 18px; + margin: 0; padding: 0; border-radius: 18px; background: var(--color-bg-elevated); diff --git a/frontend/workspace/src/atlas/FilesPane.test.ts b/frontend/workspace/src/atlas/FilesPane.test.ts index 653153cd..c812d56a 100644 --- a/frontend/workspace/src/atlas/FilesPane.test.ts +++ b/frontend/workspace/src/atlas/FilesPane.test.ts @@ -412,7 +412,7 @@ describe("files pane", () => { expect(host.querySelector('[data-tab="model.safetensors"]')).toBeNull() }) - test("renders the tab strip, the picker and a table", async () => { + test("renders the picker and table directly without a Browse tab", async () => { startOn("project") const host = mount(() => subject.FilesPane({ @@ -421,7 +421,7 @@ describe("files pane", () => { ) await new Promise((resolve) => setTimeout(resolve, 50)) - expect(host.querySelector('[data-tab="files"]')).not.toBeNull() + expect(host.querySelector('[role="tablist"]')).toBeNull() expect(host.querySelector("[data-source-button]")).not.toBeNull() expect(host.querySelector(".files-table")).not.toBeNull() expect(host.querySelectorAll("[data-file-row]").length).toBe(1) @@ -873,15 +873,11 @@ describe("files pane", () => { expect(host.querySelector(".files-table")).toBeNull() expect(host.querySelector("[data-source-button]")).toBeNull() - host.querySelector('[data-tab="files"]')?.click() - - expect(host.querySelector("[data-stub-view]")).toBeNull() - expect(host.querySelector(".files-table")).not.toBeNull() - - host.querySelector('[data-tab="train_lr.py"]')?.click() host.querySelector('[data-tab-close="train_lr.py"]')?.click() + expect(host.querySelector("[data-stub-view]")).toBeNull() expect(host.querySelector('[data-tab="train_lr.py"]')).toBeNull() + expect(host.querySelector('[role="tablist"]')).toBeNull() expect(host.querySelector(".files-table")).not.toBeNull() }) diff --git a/frontend/workspace/src/atlas/KernelPanel.test.ts b/frontend/workspace/src/atlas/KernelPanel.test.ts index 9fb2e085..64901b07 100644 --- a/frontend/workspace/src/atlas/KernelPanel.test.ts +++ b/frontend/workspace/src/atlas/KernelPanel.test.ts @@ -7,10 +7,12 @@ const card = () => readFileSync(fileURLToPath(new URL("./KernelCard.tsx", import const styles = () => readFileSync(fileURLToPath(new URL("./ComputeSurface.css", import.meta.url)), "utf8") describe("kernel control room", () => { - test("makes session ownership and runtime identity explicit", () => { + test("makes project and session ownership plus runtime identity explicit", () => { const panel = `${source()}\n${card()}` - expect(panel).toContain('aria-label="Session kernel control room"') + expect(panel).toContain('aria-label="Project kernel control room"') + expect(panel).toContain('class="kernel-session"') + expect(panel).toContain("Current session") expect(panel).toContain("data-kernel-owner={owner()}") expect(panel).toContain('class="kernel-card__identity"') expect(panel).toContain("kernel.projectID") @@ -23,8 +25,8 @@ describe("kernel control room", () => { // Stated as prose rather than as a bolded callout with an icon — this is // how kernels work, not a warning about them. - expect(panel).toContain("Named records survive app restarts.") - expect(panel).toContain("Live variables persist only while the backend process stays alive.") + expect(panel).toContain("Only process-backed runtimes count as kernels.") + expect(panel).toContain("Named environments survive app restarts") expect(panel).not.toContain("Session-owned kernels.") expect(panel).not.toContain("Project inventory") }) @@ -38,6 +40,7 @@ describe("kernel control room", () => { expect(panel).toContain("kernelCanInterrupt") expect(panel).toContain("kernelCanStop") expect(panel).toContain("kernelCanForget") + expect(panel).toContain("if (!body) return undefined as T") expect(panel).toContain("aria-label={`Restart ${kernelLabel(props.kernel)}`}") expect(panel).toContain("aria-label={`Stop ${kernelLabel(props.kernel)}`}") expect(panel).toContain("aria-label={`Forget ${kernelLabel(props.kernel)}`}") @@ -51,8 +54,9 @@ describe("kernel control room", () => { const panel = `${source()}\n${card()}` expect(panel).toContain('useExecutionAuthority("kernel")') - expect(panel).toContain('action === "restart" && !authority.allowed()') - expect(panel).toContain("restartDisabled={!authority.allowed()}") + expect(panel).toContain('if (action === "restart")') + expect(panel).toContain("kernel.sessionID !== route()") + expect(panel).toContain("restartDisabled={kernel.sessionID !== route() || !authority.allowed()}") expect(panel).toContain("disabled={!!props.action || props.restartDisabled}") expect(panel).toContain("disabled={!!props.action || !kernelCanStop(props.kernel)}") expect(panel).toContain("disabled={!!props.action || !kernelCanInterrupt(props.kernel)}") @@ -75,9 +79,10 @@ describe("kernel control room", () => { const panel = source() const runtime = readFileSync(fileURLToPath(new URL("../notebook/runtime.ts", import.meta.url)), "utf8") - // The session id comes straight from the route params, and the poll names - // its client so two panels do not share one sampling window on this route. - expect(panel).toContain("{ sessionID: params.id, client }") + // The inventory is project-wide, while the route only marks the current + // session and gates process-starting controls. + expect(panel).toContain("{ client }") + expect(panel).not.toContain("{ sessionID: params.id, client }") expect(panel).not.toContain("Omit { expect(runtime).toContain("process_identity_verified: boolean | null") }) + test("keeps kernel cards mounted when project inventory polls", () => { + const panel = source() + + // Session ids are stable primitives. Rebuilding wrapper objects here makes + // Solid remount every session group on each poll and collapses an expanded + // kernel card while the user is reading it. + expect(panel).toContain("[...grouped().keys()].sort") + expect(panel).toContain("") + expect(panel).toContain("{(sessionID) => (") + expect(panel).not.toContain(".map(([sessionID, items]) => ({") + }) + test("nests the kernel as its own card with a printed-record metric grid", () => { const css = styles() @@ -128,11 +145,11 @@ describe("kernel control room", () => { expect(panel).toContain("const transport = props.request ?? useSDK().request") }) - test("names the empty state for live kernels and scopes its promise to this session", () => { + test("names the empty state for live kernels across the project", () => { const panel = source() expect(panel).toContain("No live kernels") - expect(panel).toContain("Kernels appear here the moment this session starts computing.") + expect(panel).toContain("Kernels appear here when any session in this project starts a runtime.") expect(panel).not.toContain("on this machine") }) @@ -153,9 +170,9 @@ describe("kernel control room", () => { // An empty list after a failed poll is not "No live kernels" — the panel // does not know that. Degrading visibly is the difference between a poll - // that failed and a session that is genuinely idle. + // that failed and a project that is genuinely idle. expect(panel).toContain('{view.error ? "Kernel inventory unavailable" : "No live kernels"}') - expect(panel).toContain("The last poll could not read this session's kernels") + expect(panel).toContain("The last poll could not read this project's kernels") expect(panel).toContain("Kernel inventory unavailable. ${view.error}") }) }) diff --git a/frontend/workspace/src/atlas/KernelPanel.tsx b/frontend/workspace/src/atlas/KernelPanel.tsx index 52f2f656..29e4de43 100644 --- a/frontend/workspace/src/atlas/KernelPanel.tsx +++ b/frontend/workspace/src/atlas/KernelPanel.tsx @@ -2,8 +2,9 @@ import { For, Show, createMemo, createResource, onCleanup, type JSX } from "soli import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" import { IconCpu } from "@/atlas/shared/Icon" -import { type KernelStatus } from "@/notebook/runtime" +import { kernelLabel, kernelLanguageLabel, type KernelStatus } from "@/notebook/runtime" import { useExecutionAuthority } from "./use-execution-authority" import { useKernelList } from "./use-kernel-list" import { identify } from "@/atlas/poll-identity" @@ -59,6 +60,7 @@ export function inventory(request: Promise, settled: (error: string) => vo export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { const transport = props.request ?? useSDK().request + const sync = useSync() // Per-kernel CPU is measured across the window since this caller's previous // poll, so a panel that does not name itself shares one window with every // other panel on the route — two tabs then truncate each other's window to @@ -87,15 +89,17 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { }) const request = async (path: string, init?: RequestInit, query?: Record) => { const response = await transport(path, init, query) - if (response.ok) return response.json() as Promise + if (response.ok) { + const body = await response.text() + if (!body) return undefined as T + return JSON.parse(body) as T + } const detail = await response.text().catch(() => "") throw new Error(detail || `${response.status} ${response.statusText}`) } const load = () => { - if (!params.id || params.id === "new") return Promise.resolve({ kernels: [] }) - return inventory( - request("/notebook/kernels", undefined, { sessionID: params.id, client }), - (error) => setView(error ? { error } : { error: "", updated: Date.now() }), + return inventory(request("/notebook/kernels", undefined, { client }), (error) => + setView(error ? { error } : { error: "", updated: Date.now() }), ) } const [data, api] = createResource(load) @@ -110,6 +114,32 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { // load and returns the previous value while a refetch is in flight (see // HostStrip.tsx for the full mechanism). const kernels = useKernelList(() => data.latest?.kernels) + const route = () => (params.id && params.id !== "new" ? params.id : undefined) + const live = createMemo(() => kernels.filter((kernel) => kernel.active || kernel.state === "starting")) + const saved = createMemo(() => + kernels.filter( + (kernel) => + !kernel.active && + kernel.state !== "starting" && + kernel.name !== "agent" && + !kernel.name.startsWith("notebook:"), + ), + ) + const title = (sessionID: string) => sync.session.get(sessionID)?.title?.trim() || "Untitled session" + const grouped = createMemo(() => { + const grouped = new Map() + for (const kernel of live()) grouped.set(kernel.sessionID, [...(grouped.get(kernel.sessionID) ?? []), kernel]) + return grouped + }) + const groups = createMemo(() => + [...grouped().keys()].sort((a, b) => { + const current = Number(route() === b) - Number(route() === a) + if (current) return current + const activity = (sessionID: string) => + Math.max(...(grouped().get(sessionID) ?? []).map((kernel) => kernel.last_activity_at ?? kernel.started_at ?? 0)) + return activity(b) - activity(a) + }), + ) const ensureSession = async () => { if (params.id && params.id !== "new") return params.id return props.onEnsureSession?.() @@ -156,11 +186,18 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { .finally(() => setView("action", "")) } const control = (kernel: KernelStatus, action: KernelAction) => { - if (action === "restart" && !authority.allowed()) { - setView("problem", authority.message() ?? "This session cannot start a kernel.") - return + if (action === "restart") { + if (kernel.sessionID !== route()) { + setView("problem", "Open the owning session before starting or restarting this kernel.") + return + } + if (!authority.allowed()) { + setView("problem", authority.message() ?? "This session cannot start a kernel.") + return + } } const key = `${kernel.id}:${action}` + const starting = action === "restart" && !kernel.active setView({ action: key, problem: "", notice: "" }) const remove = action === "delete" return request( @@ -181,7 +218,9 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { .then((value) => { const notice = action === "restart" - ? "Kernel restarted in a fresh runtime. Previous in-memory variables and queued work were cleared." + ? starting + ? "Kernel started in a fresh runtime." + : "Kernel restarted in a fresh runtime. Previous in-memory variables and queued work were cleared." : action === "stop" ? "Kernel stopped. In-memory state was cleared. Run a cell to start fresh." : action === "delete" @@ -214,14 +253,14 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { }) return ( -
+
{/* No "Compute" eyebrow: the tab above already says it, and 5a's restraint is mostly about not saying things twice. The live/ running/queued breakdown moved onto the kernel's own metric grid, where it sits beside the figures it qualifies. */} - Session kernels + Project kernels {view.updated ? `Synced ${time(view.updated)}` : "Not synced yet"}
{/* No refresh control: the panel already polls every 2.5s and on @@ -288,7 +327,10 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { {/* Prose, not a callout. The icon and the bolded lead-in made this read as a warning about something that is simply how kernels work. */}
-

Named records survive app restarts. Live variables persist only while the backend process stays alive.

+

+ Only process-backed runtimes count as kernels. Named environments survive app restarts; live variables do + not. +

@@ -314,7 +356,7 @@ export function KernelPanel(props: KernelPanelProps = {}): JSX.Element { 0} + when={groups().length > 0} fallback={
} > -
- - {(kernel, index) => ( - void control(kernel, action)} - /> +
+ + {(sessionID) => ( +
+
+
+ {title(sessionID)} + {route() === sessionID ? "Current session" : "Project session"} +
+ + {grouped().get(sessionID)?.length ?? 0}{" "} + {grouped().get(sessionID)?.length === 1 ? "kernel" : "kernels"} + +
+
+ + {(kernel, index) => ( + void control(kernel, action)} + /> + )} + +
+
)}
+ + 0}> +
+
+ Saved environments + {saved().length} +
+ + {(kernel) => ( +
+
+ {kernelLabel(kernel)} + + {title(kernel.sessionID)} · {kernelLanguageLabel(kernel)} · not running + +
+
+ + +
+
+ )} +
+
+
) diff --git a/frontend/workspace/src/atlas/ProjectTrust.css b/frontend/workspace/src/atlas/ProjectTrust.css deleted file mode 100644 index 6633d4fe..00000000 --- a/frontend/workspace/src/atlas/ProjectTrust.css +++ /dev/null @@ -1,330 +0,0 @@ -.project-trust { - display: inline-flex; - min-width: 0; -} - -.project-trust__trigger { - all: unset; - box-sizing: border-box; - min-height: 24px; - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3px 7px 3px 6px; - border-radius: 5px; - color: var(--color-text-faint); - background: color-mix(in srgb, var(--color-bg-subtle) 74%, transparent); - cursor: pointer; - font-family: var(--font-family-sans); - font-size: 10.5px; - font-weight: 500; - line-height: 1; - white-space: nowrap; - transition: - color 140ms ease, - background 140ms ease, - box-shadow 140ms ease, - transform 100ms ease; -} - -.project-trust__trigger:hover, -.project-trust__trigger[data-expanded] { - color: var(--color-text); - background: var(--color-accent-subtle); -} - -.project-trust__trigger:active { - transform: translateY(1px); -} - -.project-trust__trigger:focus-visible, -.project-trust__action:focus-visible, -.project-trust__retry:focus-visible { - outline: 2px solid var(--color-text-interactive-base, var(--color-text)); - outline-offset: 2px; -} - -.project-trust__dot { - width: 6px; - height: 6px; - flex: 0 0 auto; - border-radius: 50%; - background: var(--color-text-faint); -} - -.project-trust__trigger--trusted .project-trust__dot { - background: var(--icon-success-base, var(--color-success)); -} - -.project-trust__trigger--untrusted .project-trust__dot, -.project-trust__trigger--revoked .project-trust__dot { - background: var(--icon-warning-base, var(--color-warning)); -} - -.project-trust__trigger--error .project-trust__dot { - background: var(--icon-critical-base, var(--color-error)); -} - -.project-trust__chevron { - opacity: 0.7; - transition: transform 140ms ease; -} - -.project-trust__trigger[data-expanded] .project-trust__chevron { - transform: rotate(180deg); -} - -[data-component="popover-content"].project-trust__popover { - width: 344px; - max-width: calc(100vw - 24px); - border-color: color-mix(in srgb, var(--color-border) 78%, transparent); - border-radius: 8px; - background: var(--color-surface-solid); - box-shadow: 0 14px 36px color-mix(in srgb, var(--color-bg) 24%, transparent); -} - -.project-trust__popover [data-slot="popover-header"] { - padding: 11px 12px 0; -} - -.project-trust__popover [data-slot="popover-title"] { - font-size: 12.5px; -} - -.project-trust__popover [data-slot="popover-body"] { - padding: 10px 12px 12px; -} - -.project-trust__body, -.project-trust__content, -.project-trust__loading, -.project-trust__error { - display: flex; - flex-direction: column; -} - -.project-trust__body { - gap: 11px; -} - -.project-trust__content { - gap: 12px; -} - -.project-trust__state { - display: grid; - grid-template-columns: 8px minmax(0, 1fr); - gap: 2px 8px; - align-items: center; -} - -.project-trust__state-mark { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--icon-warning-base, var(--color-warning)); -} - -.project-trust__content[data-state="trusted"] .project-trust__state-mark { - background: var(--icon-success-base, var(--color-success)); -} - -.project-trust__state strong { - font-family: var(--font-family-sans); - font-size: 12.5px; - font-weight: 600; - color: var(--color-text); -} - -.project-trust__state p { - grid-column: 2; - margin: 0; - color: var(--color-text-muted); - font-family: var(--font-family-sans); - font-size: 11.5px; - line-height: 1.45; -} - -.project-trust__identity { - display: grid; - grid-template-columns: 74px minmax(0, 1fr); - gap: 5px 9px; - padding: 9px 0; - border-top: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent); - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent); -} - -.project-trust__identity dt, -.project-trust__capabilities > span { - color: var(--color-text-faint); - font-family: var(--font-family-sans); - font-size: 10.5px; - font-weight: 500; -} - -.project-trust__identity dd { - min-width: 0; - margin: 0; - overflow: hidden; - color: var(--color-text); - font-family: var(--font-family-sans); - font-size: 11.5px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.project-trust__identity code { - font-family: var(--font-family-mono); - font-size: 10.5px; -} - -.project-trust__capabilities { - display: flex; - flex-direction: column; - gap: 6px; -} - -.project-trust__capabilities ul { - display: grid; - gap: 5px; - margin: 0; - padding: 0; - list-style: none; -} - -.project-trust__capabilities li { - position: relative; - padding-left: 13px; - color: var(--color-text-muted); - font-family: var(--font-family-sans); - font-size: 11.5px; - line-height: 1.35; -} - -.project-trust__capabilities li::before { - content: "—"; - position: absolute; - left: 0; - color: var(--color-text-faint); -} - -.project-trust__note { - margin: 0; - color: var(--color-text-faint); - font-family: var(--font-family-sans); - font-size: 10.5px; - line-height: 1.45; -} - -.project-trust__action { - min-height: 30px; - align-self: flex-start; - padding: 0 10px; - border: 1px solid transparent; - border-radius: 5px; - background: var(--color-text); - color: var(--color-bg); - cursor: pointer; - font-family: var(--font-family-sans); - font-size: 11.5px; - font-weight: 600; - transition: - opacity 140ms ease, - transform 100ms ease, - background 140ms ease; -} - -.project-trust__action:hover:not(:disabled) { - opacity: 0.86; -} - -.project-trust__action:active:not(:disabled) { - transform: translateY(1px); -} - -.project-trust__action--revoke { - border-color: color-mix(in srgb, var(--color-error) 38%, var(--color-border)); - background: transparent; - color: var(--color-error); -} - -.project-trust__action:disabled, -.project-trust__retry:disabled { - cursor: wait; - opacity: 0.55; -} - -.project-trust__loading, -.project-trust__error { - min-height: 72px; - justify-content: center; - gap: 7px; - color: var(--color-text-muted); - font-family: var(--font-family-sans); - font-size: 11.5px; - line-height: 1.45; -} - -.project-trust__loading-line { - height: 8px; - border-radius: 3px; - background: color-mix(in srgb, var(--color-text) 8%, transparent); - animation: project-trust-pulse 1.2s ease-in-out infinite; -} - -.project-trust__loading-line:first-child { - width: 58%; -} - -.project-trust__loading-line:last-child { - width: 86%; -} - -.project-trust__error { - color: var(--color-error); -} - -.project-trust__error p { - margin: 0; -} - -.project-trust__retry { - align-self: flex-start; - padding: 0; - border: 0; - background: transparent; - color: currentColor; - cursor: pointer; - font: inherit; - font-weight: 600; - text-decoration: underline; - text-underline-offset: 3px; -} - -@keyframes project-trust-pulse { - 50% { - opacity: 0.42; - } -} - -@media (prefers-reduced-motion: reduce) { - .project-trust__trigger, - .project-trust__chevron, - .project-trust__action { - transition: none; - } - - .project-trust__loading-line { - animation: none; - } -} - -@media (max-width: 620px) { - .project-trust__trigger { - padding-inline: 6px; - } - - .project-trust__chevron { - display: none; - } -} diff --git a/frontend/workspace/src/atlas/ProjectTrust.test.tsx b/frontend/workspace/src/atlas/ProjectTrust.test.tsx deleted file mode 100644 index b910248b..00000000 --- a/frontend/workspace/src/atlas/ProjectTrust.test.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { fileURLToPath } from "node:url" -import type { JSX } from "solid-js" -import { createServer } from "vite" -import solid from "vite-plugin-solid" -import type { ProjectTrustApi, ProjectTrustRequest, ProjectTrustStatus, ProjectTrustUpdate } from "./project-trust" - -const server = await createServer({ - root: fileURLToPath(new URL("../..", import.meta.url)), - mode: "production", - logLevel: "silent", - plugins: [solid({ ssr: false, dev: false })], - server: { middlewareMode: true }, - appType: "custom", - resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, - ssr: { - noExternal: true, - resolve: { conditions: ["browser", "production"] }, - }, -}) -const [subject, web] = await Promise.all([ - server.ssrLoadModule("/src/atlas/ProjectTrust.tsx") as Promise, - server.ssrLoadModule("solid-js/web") as Promise, -]) -const cleanups: Array<() => void> = [] -const root = "/Users/research/Lattice Lab/assay" -const base: ProjectTrustStatus = { - projectID: "prj_lattice", - root, - revision: 1, - state: "untrusted", - source: "default", - canExecuteProjectCode: false, -} - -afterAll(() => server.close()) - -afterEach(() => { - cleanups.splice(0).forEach((cleanup) => cleanup()) - document.body.replaceChildren() -}) - -const mount = (view: () => JSX.Element) => { - const host = document.createElement("div") - document.body.append(host) - cleanups.push(web.render(view, host)) - return host -} - -const settle = async () => { - await Promise.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) -} - -const trigger = () => document.querySelector(".project-trust__trigger") -const content = () => document.querySelector('[data-component="popover-content"].project-trust__popover') -const action = (label: string) => - Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.includes(label), - ) - -const render = (api: ProjectTrustApi) => - mount(() => - web.createComponent(subject.ProjectTrustControl, { - projectID: base.projectID, - name: "Lattice assay", - directory: root, - api, - }), - ) - -describe("ProjectTrustControl", () => { - test("lives inline in project context without adding a permanent pane", async () => { - const session = await Bun.file(new URL("../pages/session.tsx", import.meta.url)).text() - const title = session.indexOf('class="workspace-header__project"') - const trust = session.indexOf(" { - calls.push(calls.length) - if (calls.length === 1) throw new Error("server unavailable") - return base - }, - update: async () => base, - }) - await settle() - trigger()?.click() - await settle() - - expect(content()?.querySelector('[role="alert"]')?.textContent).toContain("server unavailable") - action("Try again")?.click() - await settle() - expect(calls).toHaveLength(2) - expect(content()?.textContent).toContain("Read-only project") - }) - - test("moves focus into the popover and closes it with Escape", async () => { - render({ - get: async () => base, - update: async () => base, - }) - await settle() - - const button = trigger() - button?.focus() - button?.click() - await settle() - expect(button?.getAttribute("aria-expanded")).toBe("true") - expect(document.activeElement).toBe(content()?.querySelector('[data-slot="popover-close-button"]') ?? null) - - window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })) - await settle() - - expect(button?.getAttribute("aria-expanded")).toBe("false") - expect(button?.tabIndex).toBe(0) - }) - - test("ships visible focus styles and reduced-motion loading behavior", async () => { - const css = await Bun.file(new URL("./ProjectTrust.css", import.meta.url)).text() - - expect(css).toContain(".project-trust__trigger:focus-visible") - expect(css).toContain(".project-trust__action:focus-visible") - expect(css).toContain("@media (prefers-reduced-motion: reduce)") - }) -}) diff --git a/frontend/workspace/src/atlas/ProjectTrust.tsx b/frontend/workspace/src/atlas/ProjectTrust.tsx deleted file mode 100644 index 04548798..00000000 --- a/frontend/workspace/src/atlas/ProjectTrust.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { For, Show, createMemo, createResource } from "solid-js" -import { createStore } from "solid-js/store" -import { Popover } from "@synsci/ui/popover" -import { IconChevronDown } from "@/atlas/shared/Icon" -import type { ProjectTrustApi, ProjectTrustStatus, ProjectTrustUpdate } from "./project-trust" -import "./ProjectTrust.css" - -const CAPABILITIES = [ - "Startup scripts and project dependency installation", - "Project plugins, skills, and MCP servers", - "Project formatters and language servers", - "Provider modules and token commands", -] as const - -export function ProjectTrustControl(props: { - projectID?: string - name: string - directory: string - api: ProjectTrustApi -}) { - const [store, setStore] = createStore({ - open: false, - action: undefined as "trust" | "revoke" | undefined, - error: undefined as string | undefined, - }) - const [trust, { mutate, refetch }] = createResource( - () => props.projectID, - (projectID) => - props.api.get({ - projectID, - directory: props.directory, - }), - ) - - const state = createMemo(() => { - if (trust.error || store.error) return "error" - return trust()?.state ?? "loading" - }) - const label = createMemo(() => { - if (state() === "trusted") return "project code on" - if (state() === "untrusted" || state() === "revoked") return "read-only" - if (state() === "error") return "trust unavailable" - return "checking trust" - }) - const error = createMemo(() => { - const value = store.error ?? trust.error - if (!value) return - return value instanceof Error ? value.message : String(value) - }) - - const update = (body: ProjectTrustUpdate, action: "trust" | "revoke") => { - const projectID = props.projectID - if (!projectID || store.action) return - setStore({ action, error: undefined }) - void props.api - .update({ - projectID, - directory: props.directory, - body, - }) - .then((next) => mutate(next)) - .catch((cause: unknown) => { - setStore("error", cause instanceof Error ? cause.message : String(cause)) - }) - .finally(() => setStore("action", undefined)) - } - - const retry = () => { - setStore("error", undefined) - void refetch() - } - - return ( -
- setStore("open", open)} - placement="bottom-start" - gutter={7} - title="Project permissions" - class="project-trust__popover" - triggerAs="button" - triggerProps={{ - type: "button", - class: `project-trust__trigger project-trust__trigger--${state()}`, - "aria-label": `${props.name} project permissions: ${label()}`, - }} - trigger={ - <> -
- - - ) -} - -function TrustContent(props: { - name: string - status: ProjectTrustStatus - action?: "trust" | "revoke" - error?: string - onTrust: () => void - onRevoke: () => void -}) { - const trusted = () => props.status.canExecuteProjectCode - return ( -
-
-
- -
-
Project
-
{props.name}
-
Canonical root
-
- {props.status.root} -
-
- -
- {trusted() ? "Executable capabilities enabled" : "Trusting this project enables"} -
    - {(capability) =>
  • {capability}
  • }
    -
-
- - -

- Review the canonical root and project-controlled files first. Trust is never granted automatically. -

- - - } - > -

- Revoking immediately blocks project code and disposes this project’s active caches. Unsaved in-memory tool or - language-service state may be lost; files on disk stay intact. You can trust it again later. -

- -
- - - - -
- ) -} diff --git a/frontend/workspace/src/atlas/RightPane.tsx b/frontend/workspace/src/atlas/RightPane.tsx index e2cb3de7..5266c988 100644 --- a/frontend/workspace/src/atlas/RightPane.tsx +++ b/frontend/workspace/src/atlas/RightPane.tsx @@ -31,6 +31,7 @@ import { MIN_PANE_WIDTH, INLINE_PANE_BREAKPOINT, clampPaneWidth, + legacyPaneWidthKey, paneWidthForViewport, paneWidthKey, readPaneWidth, @@ -201,10 +202,11 @@ export function RightPane( const artifact = artifactContext.active const project = () => props.project ?? props.route ?? window.location.pathname const session = () => props.session ?? "new" - const key = createMemo(() => paneWidthKey(project(), session())) - const legacy = createMemo(() => - props.project && props.session ? [paneWidthKey(`${props.project}/${props.session}`)] : [], - ) + const key = createMemo(() => paneWidthKey(project())) + const legacy = createMemo(() => [ + legacyPaneWidthKey(project(), session()), + ...(props.project && props.session ? [legacyPaneWidthKey(`${props.project}/${props.session}`)] : []), + ]) const initial = () => { try { return readPaneWidth(key(), localStorage, legacy()) diff --git a/frontend/workspace/src/atlas/files/FileTabs.test.ts b/frontend/workspace/src/atlas/files/FileTabs.test.ts index d2b3c502..ae65cd39 100644 --- a/frontend/workspace/src/atlas/files/FileTabs.test.ts +++ b/frontend/workspace/src/atlas/files/FileTabs.test.ts @@ -34,34 +34,35 @@ const mount = (view: () => JSX.Element) => { } describe("file tabs", () => { - test("always offers the Files tab and marks the active one", () => { + test("does not add a redundant Browse tab above the browser", () => { const host = mount(() => subject.FileTabs({ open: ["train_lr.py"], active: undefined, onSelect: () => {}, onClose: () => {} }), ) - expect(host.querySelector('[data-tab="files"]')?.getAttribute("aria-selected")).toBe("true") - expect(host.querySelector('[data-tab="files"]')?.textContent).toContain("Browse") + expect(host.querySelectorAll("[data-tab]")).toHaveLength(1) + expect(host.querySelector("[data-tab-label]")?.textContent).toBe("train_lr.py") expect(host.querySelector('[data-tab="train_lr.py"]')?.getAttribute("aria-selected")).toBe("false") }) - // The browser is "no open file", not a reserved name: a file really can be - // called `files`, and a sentinel string would hand it the browser's own tab. - test("keeps the browser tab distinct from an open file that shares its name", () => { - const picked: Array = [] + test("hides the empty tab strip while browsing before a file is opened", () => { + const host = mount(() => subject.FileTabs({ open: [], active: undefined, onSelect: () => {}, onClose: () => {} })) + + expect(host.querySelector('[role="tablist"]')).toBeNull() + }) + + test("keeps a real file named files selectable", () => { + const picked: string[] = [] const host = mount(() => subject.FileTabs({ open: ["files"], active: "files", onSelect: (id) => picked.push(id), onClose: () => {} }), ) - const tabs = [...host.querySelectorAll("[data-tab]")] - - expect(tabs.map((node) => node.getAttribute("aria-selected"))).toEqual(["false", "true"]) - tabs[0]!.click() + host.querySelector('[data-tab="files"]')?.click() - expect(picked).toEqual([undefined]) + expect(picked).toEqual(["files"]) }) test("selecting and closing report separately, and closing does not select", () => { - const picked: Array = [] + const picked: string[] = [] const closed: string[] = [] const host = mount(() => subject.FileTabs({ diff --git a/frontend/workspace/src/atlas/files/FileTabs.tsx b/frontend/workspace/src/atlas/files/FileTabs.tsx index 43e5c54d..70f168f5 100644 --- a/frontend/workspace/src/atlas/files/FileTabs.tsx +++ b/frontend/workspace/src/atlas/files/FileTabs.tsx @@ -1,84 +1,73 @@ -import { For, type JSX } from "solid-js" +import { For, Show, type JSX } from "solid-js" import { middle } from "@/atlas/files/truncate" const DRAG = "text/openscience-file-tab" export function FileTabs(props: { open: string[] - /** The open file the pane is showing, or undefined for the browser itself. */ + /** The open file the pane is showing, or undefined while browsing. */ active?: string - onSelect: (id?: string) => void + onSelect: (id: string) => void onClose: (id: string) => void onReorder?: (id: string, to: number) => void }): JSX.Element { return ( -
- - - - {(name, index) => ( - // Two sibling controls, one row: selecting a tab and closing it are - // separate actions, so neither may contain the other. A close control - // nested in the tab button (a role="button" span) was invalid content - // and folded its label into the tab's accessible name — "train_lr.py - // Close train_lr.py" announced as one control. Same shape as - // SourceMenu's row. -
- - -
- )} -
-
+ 0}> +
+ + {(name, index) => ( + // Two sibling controls, one row: selecting a tab and closing it are + // separate actions, so neither may contain the other. A close control + // nested in the tab button (a role="button" span) was invalid content + // and folded its label into the tab's accessible name — "train_lr.py + // Close train_lr.py" announced as one control. Same shape as + // SourceMenu's row. +
+ + +
+ )} +
+
+
) } diff --git a/frontend/workspace/src/atlas/files/FilesPane.css b/frontend/workspace/src/atlas/files/FilesPane.css index 11915543..6d4a0657 100644 --- a/frontend/workspace/src/atlas/files/FilesPane.css +++ b/frontend/workspace/src/atlas/files/FilesPane.css @@ -358,17 +358,6 @@ cursor: pointer; white-space: nowrap; } -.files-tab--home { - flex: 0 0 auto; -} -.files-tab--home:hover { - background: var(--color-bg-subtle); - color: var(--color-text); -} -.files-tab--home[aria-selected="true"] { - background: var(--color-surface); - color: var(--color-text); -} .files-tab:focus-visible { outline: 1px solid var(--color-text); outline-offset: -2px; diff --git a/frontend/workspace/src/atlas/project-trust.test.ts b/frontend/workspace/src/atlas/project-trust.test.ts deleted file mode 100644 index 6b873564..00000000 --- a/frontend/workspace/src/atlas/project-trust.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { OpenScienceClient } from "@synsci/sdk/v2/client" -import { projectTrustApi, type ProjectTrustStatus } from "./project-trust" - -const status: ProjectTrustStatus = { - projectID: "prj_lattice", - root: "/Users/research/lattice", - revision: 1, - state: "untrusted", - source: "default", - canExecuteProjectCode: false, -} - -describe("project trust API", () => { - test("uses the generated GET and PUT contract with the canonical root", async () => { - const calls: unknown[] = [] - const client = { - project: { - trust: { - get: async (input: unknown) => { - calls.push(["get", input]) - return { data: status } - }, - update: async (input: unknown) => { - calls.push(["update", input]) - return { - data: { - ...status, - state: "trusted", - source: "persisted", - canExecuteProjectCode: true, - }, - } - }, - }, - }, - } as unknown as OpenScienceClient - const api = projectTrustApi(client) - - expect(await api.get({ projectID: status.projectID, directory: status.root })).toEqual(status) - expect( - await api.update({ - projectID: status.projectID, - directory: status.root, - body: { trusted: true, root: status.root }, - }), - ).toMatchObject({ state: "trusted", canExecuteProjectCode: true }) - expect(calls).toEqual([ - [ - "get", - { - projectID: status.projectID, - directory: status.root, - }, - ], - [ - "update", - { - projectID: status.projectID, - directory: status.root, - body: { trusted: true, root: status.root }, - }, - ], - ]) - }) - - test("fails closed when a successful SDK response has no trust data", async () => { - const client = { - project: { - trust: { - get: async () => ({}), - update: async () => ({}), - }, - }, - } as unknown as OpenScienceClient - const api = projectTrustApi(client) - - await expect(api.get({ projectID: status.projectID, directory: status.root })).rejects.toThrow( - "project trust response was empty", - ) - await expect( - api.update({ - projectID: status.projectID, - directory: status.root, - body: { trusted: false }, - }), - ).rejects.toThrow("updated project trust response was empty") - }) -}) diff --git a/frontend/workspace/src/atlas/project-trust.ts b/frontend/workspace/src/atlas/project-trust.ts deleted file mode 100644 index 265de938..00000000 --- a/frontend/workspace/src/atlas/project-trust.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { OpenScienceClient, ProjectTrustGetResponse } from "@synsci/sdk/v2/client" - -export type ProjectTrustStatus = ProjectTrustGetResponse -export type ProjectTrustUpdate = { trusted: true; root: string } | { trusted: false } -export type ProjectTrustRequest = { - projectID: string - directory: string -} - -export interface ProjectTrustApi { - get(input: ProjectTrustRequest): Promise - update(input: ProjectTrustRequest & { body: ProjectTrustUpdate }): Promise -} - -export function projectTrustApi(client: OpenScienceClient): ProjectTrustApi { - return { - get(input) { - return client.project.trust - .get({ - projectID: input.projectID, - directory: input.directory, - }) - .then((response) => { - if (response.data) return response.data - throw new Error("The project trust response was empty.") - }) - }, - update(input) { - return client.project.trust - .update({ - projectID: input.projectID, - directory: input.directory, - body: input.body, - }) - .then((response) => { - if (response.data) return response.data - throw new Error("The updated project trust response was empty.") - }) - }, - } -} diff --git a/frontend/workspace/src/atlas/right-pane-files.test.ts b/frontend/workspace/src/atlas/right-pane-files.test.ts index 9f3bb848..77284255 100644 --- a/frontend/workspace/src/atlas/right-pane-files.test.ts +++ b/frontend/workspace/src/atlas/right-pane-files.test.ts @@ -25,7 +25,7 @@ test("keeps the explorer and selected file preview inside the contextual pane", expect(pane).toContain("when={!file.external}") expect(pane).toContain(" { - test("keys width by project route and session", () => { - expect(paneWidthKey("project-a", "session-a")).not.toBe(paneWidthKey("project-a", "session-b")) - expect(paneWidthKey("project-a", "session-a")).not.toBe(paneWidthKey("project-b", "session-a")) - expect(paneWidthKey("project-a")).toBe("openscience-context-width-v5:project-a:new") - expect(paneWidthKey("project-a")).not.toContain("openscience-context-width-v4") + test("keys width by project so the inspector does not jump between sessions", () => { + expect(paneWidthKey("project-a")).not.toBe(paneWidthKey("project-b")) + expect(paneWidthKey("project-a")).toBe("openscience-context-width-v6:project-a") + expect(paneWidthKey("project-a")).not.toContain("session-a") }) test("uses a readable default and clamps resize bounds", () => { @@ -33,14 +33,14 @@ describe("context pane layout", () => { expect(paneWidthForViewport(MAX_PANE_WIDTH, 900)).toBe(332) }) - test("reads and writes one route without leaking into another", () => { + test("reads and writes one project without leaking into another", () => { const values = new Map() const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), } - const first = paneWidthKey("project-a", "session-a") - const second = paneWidthKey("project-a", "session-b") + const first = paneWidthKey("project-a") + const second = paneWidthKey("project-b") expect(readPaneWidth(first, storage)).toBe(DEFAULT_PANE_WIDTH) savePaneWidth(first, 540, storage) @@ -54,8 +54,8 @@ describe("context pane layout", () => { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value), } - const current = paneWidthKey("project-a", "session-a") - const legacy = paneWidthKey("project-a/session-a") + const current = paneWidthKey("project-a") + const legacy = legacyPaneWidthKey("project-a", "session-a") values.set(legacy, "612") expect(readPaneWidth(current, storage, [legacy])).toBe(MAX_PANE_WIDTH) diff --git a/frontend/workspace/src/atlas/right-pane-layout.ts b/frontend/workspace/src/atlas/right-pane-layout.ts index bf04cf67..3dc20a09 100644 --- a/frontend/workspace/src/atlas/right-pane-layout.ts +++ b/frontend/workspace/src/atlas/right-pane-layout.ts @@ -4,7 +4,11 @@ export const DEFAULT_PANE_WIDTH = 400 export const INLINE_PANE_BREAKPOINT = 1100 export const INLINE_PANE_CHROME = 568 -export function paneWidthKey(project: string, session = "new") { +export function paneWidthKey(project: string) { + return `openscience-context-width-v6:${encodeURIComponent(project)}` +} + +export function legacyPaneWidthKey(project: string, session = "new") { return `openscience-context-width-v5:${encodeURIComponent(project)}:${encodeURIComponent(session)}` } diff --git a/frontend/workspace/src/atlas/store/scope.ts b/frontend/workspace/src/atlas/store/scope.ts index 3c40cb6b..38853555 100644 --- a/frontend/workspace/src/atlas/store/scope.ts +++ b/frontend/workspace/src/atlas/store/scope.ts @@ -10,6 +10,10 @@ export function workspaceScope(project: string, session: string) { return `${encodeURIComponent(clean(project, FALLBACK_PROJECT))}:${encodeURIComponent(clean(session, FALLBACK_SESSION))}` } +export function projectScope(project: string) { + return encodeURIComponent(clean(project, FALLBACK_PROJECT)) +} + export function defaultWorkspaceScope() { - return workspaceScope(FALLBACK_PROJECT, FALLBACK_SESSION) + return projectScope(FALLBACK_PROJECT) } diff --git a/frontend/workspace/src/atlas/store/ui.test.ts b/frontend/workspace/src/atlas/store/ui.test.ts index b16bc3c1..e6c5aa99 100644 --- a/frontend/workspace/src/atlas/store/ui.test.ts +++ b/frontend/workspace/src/atlas/store/ui.test.ts @@ -52,7 +52,7 @@ describe("context pane state", () => { expect(state.open()).toBe(false) }) - test("restores pane, context, and file only inside the matching project session", () => { + test("keeps pane, tabs, and files stable while the active session changes", () => { const storage = memoryStorage() const first = createContextState({ storage }) @@ -61,8 +61,9 @@ describe("context pane state", () => { first.setArtifactPaneTab("history") first.activateScope("project-a", "session-b") - expect(first.open()).toBe(false) - expect(first.file()).toBeUndefined() + expect(first.open()).toBe(true) + expect(first.file()?.path).toBe("results/a.csv") + expect(first.artifactPaneTab()).toBe("history") first.openContext("kernels") first.activateScope("project-b", "session-a") @@ -71,14 +72,14 @@ describe("context pane state", () => { const restored = createContextState({ storage }) restored.activateScope("project-a", "session-a") - expect(restored.context()).toBe("files") - expect(restored.file()?.path).toBe("results/a.csv") + expect(restored.context()).toBe("kernels") + expect(restored.files().map((file) => file.path)).toEqual(["results/a.csv"]) expect(restored.artifactPaneTab()).toBe("history") expect(restored.open()).toBe(true) restored.activateScope("project-a", "session-b") expect(restored.context()).toBe("kernels") - expect(restored.file()).toBeUndefined() + expect(restored.open()).toBe(true) }) test("keeps working in memory when storage reads and writes fail", () => { @@ -100,6 +101,38 @@ describe("context pane state", () => { expect(state.file()?.path).toBe("notes.md") }) + test("migrates the active legacy session pane into one project pane", () => { + const legacy = workspaceScope("project-a", "session-a") + const storage = memoryStorage({ + "openscience-context-state-v2": JSON.stringify({ + version: 2, + scopes: { [legacy]: { tab: "kernels", mode: "tools", open: true } }, + }), + }) + const state = createContextState({ storage }) + + state.activateScope("project-a", "session-a") + expect(state.context()).toBe("kernels") + expect(state.open()).toBe(true) + + state.activateScope("project-a", "session-b") + expect(state.context()).toBe("kernels") + expect(state.open()).toBe(true) + }) + + test("keeps prompt prefills session-scoped while the inspector is project-scoped", () => { + const state = createContextState({ storage: memoryStorage() }) + + state.activateScope("project-a", "session-a") + state.setPrefill("alpha") + state.activateScope("project-a", "session-b") + expect(state.prefill()).toBeUndefined() + + state.setPrefill("beta") + state.activateScope("project-a", "session-a") + expect(state.prefill()).toBe("alpha") + }) + test("active context toggles closed and a different context switches directly", () => { const state = createContextState() @@ -130,7 +163,7 @@ describe("context pane state", () => { expect(state.open()).toBe(false) }) - test("persists the terminal as a project-session context", () => { + test("persists the terminal as a project context", () => { const storage = memoryStorage() const state = createContextState({ storage }) @@ -143,10 +176,11 @@ describe("context pane state", () => { expect(restored.open()).toBe(true) restored.activateScope("project-a", "session-b") - expect(restored.open()).toBe(false) + expect(restored.context()).toBe("terminal") + expect(restored.open()).toBe(true) }) - test("persists the local trace as a project-session work tab", () => { + test("persists the local trace in the project work strip", () => { const storage = memoryStorage() const state = createContextState({ storage }) diff --git a/frontend/workspace/src/atlas/store/ui.ts b/frontend/workspace/src/atlas/store/ui.ts index b555f259..d3d4c16b 100644 --- a/frontend/workspace/src/atlas/store/ui.ts +++ b/frontend/workspace/src/atlas/store/ui.ts @@ -1,7 +1,7 @@ import { createSignal } from "solid-js" import { createStore } from "solid-js/store" import { normalizeStoredArtifact, type StoredArtifact } from "@/artifacts/store" -import { defaultWorkspaceScope, workspaceScope } from "./scope" +import { defaultWorkspaceScope, projectScope, workspaceScope } from "./scope" export type RightPaneTab = "files" | "terminal" | "canvas" | "kernels" | "trace" export type RightPaneMode = "artifact" | "tools" @@ -43,7 +43,7 @@ interface ContextState { tab: RightPaneTab mode: RightPaneMode open: boolean - /** Every contextual surface currently open in this project + session. */ + /** Every contextual surface currently open in this project. */ workTabs?: WorkTab[] /** The focused contextual tab. */ activeWorkTab?: string @@ -295,6 +295,7 @@ export function createContextState(options: { storage?: ContextStorage } = {}) { const storage = options.storage ?? browserStorage() const [store, setStore] = createStore({ scope: defaultWorkspaceScope(), + transientScope: workspaceScope("__workspace__", "new"), scopes: restore(storage) as Record, transient: {} as Record, }) @@ -313,12 +314,12 @@ export function createContextState(options: { storage?: ContextStorage } = {}) { } const current = () => store.scopes[store.scope] ?? empty() - const transient = () => store.transient[store.scope] ?? { send: false } + const transient = () => store.transient[store.transientScope] ?? { send: false } const update = (next: ContextState) => { setStore("scopes", store.scope, next) persist() } - const updateTransient = (next: TransientState) => setStore("transient", store.scope, next) + const updateTransient = (next: TransientState) => setStore("transient", store.transientScope, next) const context = (): ContextTab => (current().mode === "artifact" ? "artifact" : current().tab) const closeContext = () => update({ ...current(), open: false }) const select = ( @@ -486,7 +487,14 @@ export function createContextState(options: { storage?: ContextStorage } = {}) { return { scope: () => store.scope, activateScope(project: string, session: string) { - setStore("scope", workspaceScope(project, session)) + const scope = projectScope(project) + const legacy = workspaceScope(project, session) + const remembered = store.scopes[scope] + if (!remembered && store.scopes[legacy]) { + setStore("scopes", scope, store.scopes[legacy]) + persist() + } + setStore({ scope, transientScope: legacy }) }, context, open: () => current().open, diff --git a/frontend/workspace/src/pages/session-shell.test.ts b/frontend/workspace/src/pages/session-shell.test.ts index 704e7d9e..42b1098e 100644 --- a/frontend/workspace/src/pages/session-shell.test.ts +++ b/frontend/workspace/src/pages/session-shell.test.ts @@ -53,6 +53,8 @@ describe("focused workspace shell", () => { expect(session).not.toContain('class="workspace-header__menu"') expect(session).not.toContain('class="workspace-header__search"') expect(session).not.toContain("New session above.") @@ -96,7 +98,7 @@ describe("focused workspace shell", () => { expect(action).toContain('ariaLabel="Open project terminal"') expect(action).toContain('ariaLabel="Open Atlas"') expect(action).not.toContain('ariaLabel="Open Evidence"') - expect(action).toContain('ariaLabel="Open session compute"') + expect(action).toContain('ariaLabel="Open project compute"') expect(action).toContain('ariaLabel="Open file details"') expect(session).toContain("context={uiStore.context()}") expect(session).toContain("contextOpen={uiStore.open()}") @@ -203,10 +205,10 @@ describe("focused workspace shell", () => { expect(styles).toContain('.session-sidebar[data-collapsed="true"]') }) - test("keeps research tools in a route-owned contextual surface", () => { + test("keeps research tools in a project-owned contextual surface", () => { const pane = read("../atlas/RightPane.tsx") - expect(pane).toContain("paneWidthKey(project(), session())") + expect(pane).toContain("paneWidthKey(project())") expect(pane).toContain("DEFAULT_PANE_WIDTH") expect(pane).toContain('"min(520px, calc(100vw - 48px))"') expect(pane).toContain('aria-label="Research inspector"') diff --git a/frontend/workspace/src/pages/session-sidebar-action.test.tsx b/frontend/workspace/src/pages/session-sidebar-action.test.tsx index 196d1c29..b61ab8fc 100644 --- a/frontend/workspace/src/pages/session-sidebar-action.test.tsx +++ b/frontend/workspace/src/pages/session-sidebar-action.test.tsx @@ -178,7 +178,7 @@ describe("SessionSidebarActions", () => { await Promise.resolve() expect(state.open()).toBe(false) - button(host, "Open session compute")?.click() + button(host, "Open project compute")?.click() await Promise.resolve() expect(state.context()).toBe("kernels") diff --git a/frontend/workspace/src/pages/session-sidebar-action.tsx b/frontend/workspace/src/pages/session-sidebar-action.tsx index d47dd017..39da35b7 100644 --- a/frontend/workspace/src/pages/session-sidebar-action.tsx +++ b/frontend/workspace/src/pages/session-sidebar-action.tsx @@ -143,8 +143,8 @@ export function SessionSidebarActions(props: { props.onContext("kernels")} > diff --git a/frontend/workspace/src/pages/session.tsx b/frontend/workspace/src/pages/session.tsx index 3689979d..acee9078 100644 --- a/frontend/workspace/src/pages/session.tsx +++ b/frontend/workspace/src/pages/session.tsx @@ -59,8 +59,6 @@ import { IconTrash } from "@/atlas/shared/Icon" import { toast } from "@/atlas/Toast" import { artifactContext } from "@/artifacts/context" import { createSessionTabs } from "@/atlas/store/sessionTabs" -import { ProjectTrustControl } from "@/atlas/ProjectTrust" -import { projectTrustApi, type ProjectTrustApi } from "@/atlas/project-trust" import { terminalEndpointAvailable } from "@/atlas/terminal-endpoint" import { productPreferences, type ProductPreferences } from "@/context/product-preferences" import { SIDEBAR_WIDTH, clampSidebarWidth } from "@/pages/session-sidebar-size" @@ -118,7 +116,6 @@ export default function Page(): JSX.Element { const server = useServer() const platform = usePlatform() const dialog = useDialog() - const trust = projectTrustApi(sdk.client) const [creating, setCreating] = createSignal(false) const pending: { value?: Promise; context?: SessionContext } = {} const [mobileSessionsOpen, setMobileSessionsOpen] = createSignal(false) @@ -152,7 +149,7 @@ export default function Page(): JSX.Element { async function ensureSession() { if (params.id && params.id !== "new") return params.id const context = uiStore.context() - if ((["terminal", "files", "kernels"] as SessionContext[]).includes(context as SessionContext)) { + if (context === "terminal") { pending.context = context as SessionContext } if (pending.value) return pending.value @@ -192,7 +189,7 @@ export default function Page(): JSX.Element { const openContext = (context: SessionContext) => { if (context === "canvas" && !atlasAvailable()) return uiStore.openContext(context) - if (!(["terminal", "files", "kernels"] as SessionContext[]).includes(context)) return + if (context !== "terminal") return void ensureSession() } @@ -759,10 +756,6 @@ export default function Page(): JSX.Element { >
navigate("/")} onRunReview={() => void runReview()} reviewDisabled={reviewDisabled()} @@ -1175,10 +1168,6 @@ function SessionTabStrip(props: { function Header(props: { title: string - projectID?: string - projectName: string - directory: string - trust: ProjectTrustApi onBack: () => void onRunReview: () => void reviewDisabled: boolean @@ -1202,12 +1191,6 @@ function Header(props: { {props.title} -