From 45e4c61608e56f301edb31cd452bf0ab63f475b2 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 2 Aug 2026 18:15:40 +0800 Subject: [PATCH 1/7] fix workspace flows and local data migration --- backend/cli/src/agent/agent.ts | 10 +- backend/cli/src/cli/onboard.ts | 11 ++ backend/cli/src/global/data-dir.ts | 120 ++++++++++++ backend/cli/src/global/index.ts | 29 +-- backend/cli/src/server/routes/session.ts | 4 + .../cli/src/server/routes/settings/storage.ts | 2 +- backend/cli/src/session/index.ts | 5 +- backend/cli/src/skill/install/install.ts | 2 +- backend/cli/test/agent/agent.test.ts | 18 +- backend/cli/test/global/data-dir.test.ts | 69 +++++++ backend/cli/test/session/session.test.ts | 27 +++ .../workspace/src/atlas/ComputeJobs.test.ts | 8 + frontend/workspace/src/atlas/ComputeJobs.tsx | 55 ++++-- .../workspace/src/atlas/ComputeSurface.tsx | 9 +- .../workspace/src/atlas/FileExplorer.test.ts | 9 +- frontend/workspace/src/atlas/FileExplorer.tsx | 47 +++-- .../workspace/src/atlas/KernelPanel.test.ts | 9 + frontend/workspace/src/atlas/KernelPanel.tsx | 42 +++- frontend/workspace/src/atlas/RightPane.tsx | 63 ++++-- frontend/workspace/src/atlas/SkillsPage.tsx | 27 ++- .../src/atlas/file-location-menu.test.ts | 8 +- .../src/atlas/right-pane-files.test.ts | 4 +- .../src/atlas/right-pane-surface.test.ts | 4 +- frontend/workspace/src/atlas/shared/Icon.tsx | 2 + .../src/components/dialog-settings.tsx | 4 +- .../components/mobile-compose-model.test.ts | 8 +- .../workspace/src/components/prompt-input.css | 66 +++++++ .../workspace/src/components/prompt-input.tsx | 67 ++++++- .../src/components/settings/Memory.tsx | 14 +- .../src/components/settings/Storage.tsx | 4 +- .../src/components/settings/registry.ts | 13 +- .../components/settings/truth-pass.test.ts | 5 +- frontend/workspace/src/context/sync.tsx | 23 +++ .../workspace/src/pages/session-shell.test.ts | 14 +- frontend/workspace/src/pages/session.tsx | 183 +++++++++++++++--- frontend/workspace/src/styles/atlas.css | 137 ++++++++++++- tooling/launcher/bin/synsci.mjs | 19 +- tooling/sdk/js/src/v2/gen/sdk.gen.ts | 37 +++- tooling/sdk/js/src/v2/gen/types.gen.ts | 29 +++ tooling/sdk/openapi.json | 86 +++++++- 40 files changed, 1131 insertions(+), 162 deletions(-) create mode 100644 backend/cli/src/global/data-dir.ts create mode 100644 backend/cli/test/global/data-dir.test.ts diff --git a/backend/cli/src/agent/agent.ts b/backend/cli/src/agent/agent.ts index b37686ef..c1979ded 100644 --- a/backend/cli/src/agent/agent.ts +++ b/backend/cli/src/agent/agent.ts @@ -105,7 +105,7 @@ export namespace Agent { }), user, ), - mode: "all", + mode: "subagent", native: true, }, // --- Physics --- @@ -122,7 +122,7 @@ export namespace Agent { }), user, ), - mode: "all", + mode: "subagent", native: true, }, // --- Machine learning --- @@ -139,7 +139,7 @@ export namespace Agent { }), user, ), - mode: "all", + mode: "subagent", native: true, }, // --- Utilities --- @@ -450,7 +450,9 @@ export namespace Agent { return agent.name } - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + const primaryVisible = Object.values(agents).find( + (agent) => agent.mode !== "subagent" && agent.hidden !== true && agent.name !== "plan", + ) if (!primaryVisible) throw new Error("no primary visible agent found") return primaryVisible.name } diff --git a/backend/cli/src/cli/onboard.ts b/backend/cli/src/cli/onboard.ts index 54982256..2f8fb531 100644 --- a/backend/cli/src/cli/onboard.ts +++ b/backend/cli/src/cli/onboard.ts @@ -230,6 +230,17 @@ export const DoctorCommand = cmd({ prompts.log.info(`Cache root: ${Global.Path.cache}`) prompts.log.info(`State root: ${Global.Path.state}`) + if (Global.DataMigration.migrated) { + prompts.log.success( + `Data copied to ~/.openscience and verified (${Global.DataMigration.migrated.files} files). The previous XDG directory remains as a safety copy.`, + ) + } + if (Global.DataMigration.error) { + prompts.log.warn( + `Data migration to ~/.openscience did not complete; OpenScience is still using the previous directory. ${Global.DataMigration.error}`, + ) + } + if (Global.LegacyConflicts.length) { prompts.log.warn( `Legacy data directories are ignored because current directories exist: ${Global.LegacyConflicts.map((item) => item.legacy).join(", ")}. Merge or remove them.`, diff --git a/backend/cli/src/global/data-dir.ts b/backend/cli/src/global/data-dir.ts new file mode 100644 index 00000000..3575b6a1 --- /dev/null +++ b/backend/cli/src/global/data-dir.ts @@ -0,0 +1,120 @@ +import fs from "fs/promises" +import { createHash } from "node:crypto" +import { createReadStream } from "node:fs" +import path from "node:path" + +const reserved = new Set(["bin", ".xdg-data-migration-v1.json"]) + +export interface DataResolution { + path: string + migrated?: { source: string; target: string; files: number; bytes: number } + conflict?: { legacy: string; current: string } + error?: string +} + +async function entries(root: string) { + return fs.readdir(root, { withFileTypes: true }).catch(() => []) +} + +async function hash(file: string) { + const value = createHash("sha256") + for await (const chunk of createReadStream(file)) value.update(chunk) + return value.digest("hex") +} + +async function manifest(root: string) { + const stack = [root] + const files: Array<{ path: string; bytes: number; sha256: string }> = [] + while (stack.length) { + const dir = stack.pop() + if (!dir) continue + for (const entry of await entries(dir)) { + const full = path.join(dir, entry.name) + if (entry.isSymbolicLink()) continue + if (entry.isDirectory()) { + stack.push(full) + continue + } + if (!entry.isFile()) continue + const stat = await fs.stat(full) + files.push({ path: path.relative(root, full), bytes: stat.size, sha256: await hash(full) }) + } + } + return files.sort((a, b) => a.path.localeCompare(b.path)) +} + +async function copy(source: string, target: string) { + await fs.mkdir(target, { recursive: true }) + for (const entry of await entries(source)) { + if (reserved.has(entry.name) || entry.isSymbolicLink()) continue + await fs.cp(path.join(source, entry.name), path.join(target, entry.name), { + recursive: true, + force: false, + errorOnExist: true, + }) + } +} + +export async function resolveDataDirectory(input: { + home: string + legacy: string + explicit?: string + pointer?: string +}): Promise { + if (input.explicit) return { path: path.resolve(input.explicit) } + if (input.pointer) return { path: path.resolve(input.pointer) } + + const target = path.join(path.resolve(input.home), ".openscience") + const legacy = path.resolve(input.legacy) + const migrated = await fs + .stat(path.join(target, ".xdg-data-migration-v1.json")) + .then((stat) => stat.isFile()) + .catch(() => false) + if (migrated) return { path: target } + const source = await entries(legacy) + if (source.length === 0 || legacy === target) return { path: target } + + const occupied = (await entries(target)).filter((entry) => !reserved.has(entry.name)) + if (occupied.length > 0) { + return { path: target, conflict: { legacy, current: target } } + } + + const stage = await fs.mkdtemp(path.join(path.resolve(input.home), ".openscience-migrate-")) + const moved: string[] = [] + const result = await copy(legacy, stage) + .then(async () => { + const before = await manifest(stage) + const original = (await manifest(legacy)).filter((file) => !reserved.has(file.path.split(path.sep)[0])) + if (JSON.stringify(before) !== JSON.stringify(original)) throw new Error("checksum verification failed") + await fs.mkdir(target, { recursive: true }) + for (const entry of await entries(stage)) { + await fs.rename(path.join(stage, entry.name), path.join(target, entry.name)) + moved.push(entry.name) + } + const bytes = before.reduce((total, file) => total + file.bytes, 0) + const migrated = { source: legacy, target, files: before.length, bytes } + await Bun.write( + path.join(target, ".xdg-data-migration-v1.json"), + `${JSON.stringify({ ...migrated, migratedAt: Date.now() }, null, 2)}\n`, + { mode: 0o600 }, + ) + return { path: target, migrated } satisfies DataResolution + }) + .catch(async (error: unknown) => { + await Promise.all( + moved + .reverse() + .map((name) => + fs + .rename(path.join(target, name), path.join(stage, name)) + .catch(() => fs.rm(path.join(target, name), { recursive: true, force: true })), + ), + ) + return { + path: legacy, + error: error instanceof Error ? error.message : String(error), + } + }) + await fs.rm(stage, { recursive: true, force: true }) + return result +} diff --git a/backend/cli/src/global/index.ts b/backend/cli/src/global/index.ts index 19422f13..c310a60f 100644 --- a/backend/cli/src/global/index.ts +++ b/backend/cli/src/global/index.ts @@ -3,6 +3,7 @@ import { readFileSync, existsSync, renameSync } from "fs" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" +import { resolveDataDirectory } from "./data-dir" const app = "openscience" @@ -53,21 +54,24 @@ const config = override("OPENSCIENCE_CONFIG_DIR") ?? migrateDir(xdgConfig!) const state = migrateDir(xdgState!) // The data directory can be relocated from settings ▸ Storage. When a pointer -// file exists (config/data-location) we honour it; otherwise the XDG default. -// Read synchronously at boot so every Global.Path.data consumer sees one value. -function resolveDataDir(): string { - const explicit = override("OPENSCIENCE_DATA_DIR") - if (explicit) return explicit - const fallback = migrateDir(xdgData!) +// file exists (config/data-location) we honour it; otherwise ~/.openscience. +// Resolve once at boot so every Global.Path.data consumer sees one value. +const explicit = override("OPENSCIENCE_DATA_DIR") +const pointer = (() => { try { - const pointer = readFileSync(path.join(config, "data-location"), "utf8").trim() - return pointer ? path.resolve(pointer) : fallback + return readFileSync(path.join(config, "data-location"), "utf8").trim() || undefined } catch { - return fallback + return } -} - -const data = resolveDataDir() +})() +const resolved = await resolveDataDirectory({ + home: process.env.OPENSCIENCE_TEST_HOME || os.homedir(), + legacy: migrateDir(xdgData!), + explicit, + pointer, +}) +const data = resolved.path +if (resolved.conflict) detectedLegacyConflicts.push(resolved.conflict) // Legacy file names inside the migrated dirs (pre-rename releases). migrateFile(data, "synsci-session.json", "openscience-session.json") @@ -77,6 +81,7 @@ migrateFile(config, "synsc.json", "openscience.json") export namespace Global { export const LegacyConflicts = detectedLegacyConflicts as readonly { legacy: string; current: string }[] + export const DataMigration = resolved export const Path = { // Allow override via OPENSCIENCE_TEST_HOME for test isolation get home() { diff --git a/backend/cli/src/server/routes/session.ts b/backend/cli/src/server/routes/session.ts index b93d6e5b..2690d740 100644 --- a/backend/cli/src/server/routes/session.ts +++ b/backend/cli/src/server/routes/session.ts @@ -439,6 +439,7 @@ export const SessionRoutes = lazy(() => time: z .object({ archived: z.number().optional(), + pinned: z.number().optional(), }) .optional(), }), @@ -454,6 +455,9 @@ export const SessionRoutes = lazy(() => session.title = updates.title } if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived + if (updates.time?.pinned !== undefined) { + session.time.pinned = updates.time.pinned || undefined + } }, { touch: false }, ) diff --git a/backend/cli/src/server/routes/settings/storage.ts b/backend/cli/src/server/routes/settings/storage.ts index 8a192557..5a8e5b41 100644 --- a/backend/cli/src/server/routes/settings/storage.ts +++ b/backend/cli/src/server/routes/settings/storage.ts @@ -135,7 +135,7 @@ export const StorageRoutes = lazy(() => "/location", describeRoute({ summary: "Reset data location", - description: "Remove the data-location pointer so the default location is used on next launch.", + description: "Remove the data-location pointer so ~/.openscience is used on next launch.", operationId: "settings.storage.resetLocation", responses: { 200: { diff --git a/backend/cli/src/session/index.ts b/backend/cli/src/session/index.ts index 5dbe954a..a3fcde2d 100644 --- a/backend/cli/src/session/index.ts +++ b/backend/cli/src/session/index.ts @@ -34,10 +34,12 @@ export namespace Session { const childTitlePrefix = "Child session - " function createDefaultTitle(isChild = false) { - return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() + if (!isChild) return "New session" + return childTitlePrefix + new Date().toISOString() } export function isDefaultTitle(title: string) { + if (title === "New session") return true return new RegExp( `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, ).test(title) @@ -80,6 +82,7 @@ export namespace Session { updated: z.number(), compacting: z.number().optional(), archived: z.number().optional(), + pinned: z.number().optional(), }), permission: PermissionNext.Ruleset.optional(), revert: z diff --git a/backend/cli/src/skill/install/install.ts b/backend/cli/src/skill/install/install.ts index 375d8fa0..76c7e9f4 100644 --- a/backend/cli/src/skill/install/install.ts +++ b/backend/cli/src/skill/install/install.ts @@ -30,7 +30,7 @@ export interface InstallResult { } function installedDir(): string { - // Same path the loader scans (Global.Path.data resolves XDG_DATA_HOME first). + // Same path the loader scans (Global.Path.data defaults to ~/.openscience). // Allow tests to override via OPENSCIENCE_DATA_DIR without monkey-patching globals. const base = process.env.OPENSCIENCE_DATA_DIR ?? Global.Path.data return path.join(base, "installed-skills") diff --git a/backend/cli/test/agent/agent.test.ts b/backend/cli/test/agent/agent.test.ts index 80a9d881..ff8cbcca 100644 --- a/backend/cli/test/agent/agent.test.ts +++ b/backend/cli/test/agent/agent.test.ts @@ -42,6 +42,18 @@ test("research agent has correct default properties", async () => { }) }) +test("domain agents are delegated specialists instead of competing primary modes", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect((await Agent.get("biology"))?.mode).toBe("subagent") + expect((await Agent.get("physics"))?.mode).toBe("subagent") + expect((await Agent.get("ml"))?.mode).toBe("subagent") + }, + }) +}) + test("plan agent denies edits except .openscience/plans/*", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -625,7 +637,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn }) }) -test("defaultAgent returns next primary agent when first is disabled", async () => { +test("defaultAgent does not silently replace disabled research with plan mode", async () => { await using tmp = await tmpdir({ config: { agent: { @@ -636,9 +648,7 @@ test("defaultAgent returns next primary agent when first is disabled", async () await Instance.provide({ directory: tmp.path, fn: async () => { - const agent = await Agent.defaultAgent() - // research is disabled, so it should return the next primary agent - expect(agent).toBe("biology") + await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found") }, }) }) diff --git a/backend/cli/test/global/data-dir.test.ts b/backend/cli/test/global/data-dir.test.ts new file mode 100644 index 00000000..e49db49f --- /dev/null +++ b/backend/cli/test/global/data-dir.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "node:os" +import path from "node:path" +import { resolveDataDirectory } from "@/global/data-dir" + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))) +}) + +async function root() { + const value = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-data-dir-")) + roots.push(value) + return value +} + +describe("OpenScience data directory", () => { + test("prefers explicit and pointer locations without migrating", async () => { + const home = await root() + const legacy = path.join(home, "share", "openscience") + + expect((await resolveDataDirectory({ home, legacy, explicit: "./custom" })).path).toBe(path.resolve("./custom")) + expect((await resolveDataDirectory({ home, legacy, pointer: "./pointed" })).path).toBe(path.resolve("./pointed")) + }) + + test("copies, checksums, and retains legacy data before selecting ~/.openscience", async () => { + const home = await root() + const legacy = path.join(home, "share", "openscience") + await fs.mkdir(path.join(legacy, "storage"), { recursive: true }) + await fs.writeFile(path.join(legacy, "storage", "session.json"), '{"title":"kept"}\n') + await fs.mkdir(path.join(home, ".openscience", "bin"), { recursive: true }) + await fs.writeFile(path.join(home, ".openscience", "bin", "openscience"), "launcher") + + const result = await resolveDataDirectory({ home, legacy }) + + expect(result.path).toBe(path.join(home, ".openscience")) + expect(result.migrated?.files).toBe(1) + expect(await fs.readFile(path.join(result.path, "storage", "session.json"), "utf8")).toContain("kept") + expect(await fs.readFile(path.join(legacy, "storage", "session.json"), "utf8")).toContain("kept") + expect(await fs.readFile(path.join(result.path, "bin", "openscience"), "utf8")).toBe("launcher") + expect(JSON.parse(await fs.readFile(path.join(result.path, ".xdg-data-migration-v1.json"), "utf8")).source).toBe( + legacy, + ) + + const repeated = await resolveDataDirectory({ home, legacy }) + expect(repeated.path).toBe(result.path) + expect(repeated.migrated).toBeUndefined() + expect(repeated.conflict).toBeUndefined() + }) + + test("reports a conflict instead of merging two populated roots", async () => { + const home = await root() + const legacy = path.join(home, "share", "openscience") + const target = path.join(home, ".openscience") + await fs.mkdir(legacy, { recursive: true }) + await fs.mkdir(target, { recursive: true }) + await fs.writeFile(path.join(legacy, "old.json"), "old") + await fs.writeFile(path.join(target, "new.json"), "new") + + const result = await resolveDataDirectory({ home, legacy }) + + expect(result.path).toBe(target) + expect(result.conflict).toEqual({ legacy, current: target }) + expect(await fs.readFile(path.join(target, "new.json"), "utf8")).toBe("new") + expect(await fs.readFile(path.join(legacy, "old.json"), "utf8")).toBe("old") + }) +}) diff --git a/backend/cli/test/session/session.test.ts b/backend/cli/test/session/session.test.ts index 219cef12..0b5d9743 100644 --- a/backend/cli/test/session/session.test.ts +++ b/backend/cli/test/session/session.test.ts @@ -4,11 +4,38 @@ import { Session } from "../../src/session" import { Bus } from "../../src/bus" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) describe("session.started event", () => { + test("creates a clean default title and persists pin metadata through the session API", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + expect(session.title).toBe("New session") + expect(Session.isDefaultTitle(session.title)).toBe(true) + expect(Session.isDefaultTitle("New session - 2026-01-01T00:00:00.000Z")).toBe(true) + + const response = await Server.internalFetch()( + `http://openscience.internal/session/${session.id}?directory=${encodeURIComponent(projectRoot)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ time: { pinned: 123 } }), + }, + ) + expect(response.status).toBe(200) + const updated = (await response.json()) as Session.Info + expect(updated.time.pinned).toBe(123) + + await Session.remove(session.id) + }, + }) + }) + test("should emit session.started event when session is created", async () => { await Instance.provide({ directory: projectRoot, diff --git a/frontend/workspace/src/atlas/ComputeJobs.test.ts b/frontend/workspace/src/atlas/ComputeJobs.test.ts index e1a3eec4..c6fbf23e 100644 --- a/frontend/workspace/src/atlas/ComputeJobs.test.ts +++ b/frontend/workspace/src/atlas/ComputeJobs.test.ts @@ -100,4 +100,12 @@ describe("compute jobs surface", () => { expect(source).toContain('job().target.kind === "local"') expect(source).toContain("Remote dispatch is unavailable") }) + + test("materializes a new session before opening or dispatching a job and polls only while active", () => { + expect(source).toContain("props.onEnsureSession?.()") + expect(source).toContain("const sessionID = await ensureSession()") + expect(source).toContain("if (active() === 0) return") + expect(source).toContain("setInterval") + expect(source).not.toContain("Save the session before starting") + }) }) diff --git a/frontend/workspace/src/atlas/ComputeJobs.tsx b/frontend/workspace/src/atlas/ComputeJobs.tsx index 81e46c60..202c0958 100644 --- a/frontend/workspace/src/atlas/ComputeJobs.tsx +++ b/frontend/workspace/src/atlas/ComputeJobs.tsx @@ -23,7 +23,7 @@ import { const terminal = new Set(["succeeded", "failed", "cancelled", "interrupted"]) -export function ComputeJobs(): JSX.Element { +export function ComputeJobs(props: { onEnsureSession?: () => Promise } = {}): JSX.Element { const sdk = useSDK() const params = useParams() const api = createComputeJobsAPI(sdk.request) @@ -60,11 +60,32 @@ export function ComputeJobs(): JSX.Element { if (!selected() || !list.some((job) => job.id === selected())) setSelected(list[0].id) }) - const timer = setInterval(() => { - void jobsApi.refetch() - if (selected()) void outputApi.refetch() - }, 1_500) - onCleanup(() => clearInterval(timer)) + createEffect(() => { + if (active() === 0) return + const timer = setInterval(() => { + void jobsApi.refetch() + if (selected() && current() && !terminal.has(current()!.status)) void outputApi.refetch() + }, 2_500) + onCleanup(() => clearInterval(timer)) + }) + + const ensureSession = async () => { + if (params.id && params.id !== "new") return params.id + return props.onEnsureSession?.() + } + + const begin = async () => { + if (creating()) { + setCreating(false) + return + } + const id = await ensureSession() + if (!id) { + toast.error("job setup unavailable", "OpenScience could not create a session for this job.") + return + } + setCreating(true) + } const reset = () => { setName("") @@ -109,9 +130,9 @@ export function ComputeJobs(): JSX.Element { const start = async () => { if (!ready()) return - const sessionID = params.id - if (!sessionID || sessionID === "new") { - toast.error("job did not start", "Save the session before starting a research job.") + const sessionID = await ensureSession() + if (!sessionID) { + toast.error("job did not start", "OpenScience could not create a session for this job.") return } if (!authority.allowed()) { @@ -220,14 +241,10 @@ export function ComputeJobs(): JSX.Element { setCreating((value) => !value)} + disabled={busy()} + onClick={() => void begin()} > @@ -453,9 +470,9 @@ export function ComputeJobs(): JSX.Element { diff --git a/frontend/workspace/src/atlas/ComputeSurface.tsx b/frontend/workspace/src/atlas/ComputeSurface.tsx index c7cbf06d..b62c717f 100644 --- a/frontend/workspace/src/atlas/ComputeSurface.tsx +++ b/frontend/workspace/src/atlas/ComputeSurface.tsx @@ -7,8 +7,9 @@ import "@/atlas/ComputeSurface.css" type Tab = "kernels" | "jobs" type ComputeSurfaceProps = { - kernels?: Component - jobs?: Component + kernels?: Component<{ onEnsureSession?: () => Promise }> + jobs?: Component<{ onEnsureSession?: () => Promise }> + onEnsureSession?: () => Promise } const tabs = [ @@ -84,7 +85,7 @@ export function ComputeSurface(props: ComputeSurfaceProps = {}): JSX.Element { aria-labelledby={`${id}-kernels-tab`} tabindex={0} > - + @@ -95,7 +96,7 @@ export function ComputeSurface(props: ComputeSurfaceProps = {}): JSX.Element { aria-labelledby={`${id}-jobs-tab`} tabindex={0} > - + diff --git a/frontend/workspace/src/atlas/FileExplorer.test.ts b/frontend/workspace/src/atlas/FileExplorer.test.ts index 20217660..766007aa 100644 --- a/frontend/workspace/src/atlas/FileExplorer.test.ts +++ b/frontend/workspace/src/atlas/FileExplorer.test.ts @@ -8,15 +8,16 @@ describe("file explorer surface", () => { test("organizes Files around sources instead of a host filesystem root", () => { const value = source() - expect(value).toContain('title="Session files"') - expect(value).toContain('title="Project folder"') - expect(value).toContain('detail="Already connected"') + expect(value).toContain('title="Workspace"') + expect(value).toContain('detail="Local and session files"') + expect(value).toContain("Session workspace") + expect(value).toContain("Browse files") expect(value).toContain('title="Artifacts"') expect(value).toContain('title="Connected locations"') expect(value).toContain("FilesSourceList") expect(value).toContain("connectedFilesystemGrants") expect(value).toContain("sessionFilesystemRoot") - expect(value).toContain('detail="Writable workspace"') + expect(value).toContain("formOverlay()") expect(value).not.toContain("This computer") expect(value).not.toContain('placeholder="/absolute/path"') expect(value).not.toContain("onHome") diff --git a/frontend/workspace/src/atlas/FileExplorer.tsx b/frontend/workspace/src/atlas/FileExplorer.tsx index 97f73f55..5f70d7d9 100644 --- a/frontend/workspace/src/atlas/FileExplorer.tsx +++ b/frontend/workspace/src/atlas/FileExplorer.tsx @@ -201,21 +201,22 @@ export function FilesSourceList(props: FilesSourceListProps): JSX.Element { flex: 1, "min-height": 0, overflow: "auto", - padding: "16px", + padding: "14px", display: "flex", "flex-direction": "column", - gap: "20px", - background: "var(--color-surface-solid)", + gap: "14px", + position: "relative", + background: "var(--color-bg)", "font-family": FONT_SANS, }} > -

- Files stay in their source. Access can last for one request, this session, this project, or every project; - nothing is silently moved or uploaded. -

+
+ Browse files +

Choose a workspace, a saved artifact, or a connected location.

+
-
- +
+ -

Your existing project root stays attached when you move to this version.

-
- -
- } > -
+
Connect a file or folder @@ -1203,6 +1196,18 @@ const formCard = (): JSX.CSSProperties => ({ background: "var(--color-bg-subtle)", }) +const formOverlay = (): JSX.CSSProperties => ({ + ...formCard(), + position: "absolute", + inset: "12px", + "z-index": 8, + overflow: "auto", + "align-self": "stretch", + "justify-content": "flex-start", + background: "var(--color-bg-elevated)", + "box-shadow": "0 18px 42px color-mix(in srgb, var(--color-bg) 38%, transparent)", +}) + const formHead = (): JSX.CSSProperties => ({ display: "flex", "align-items": "flex-start", diff --git a/frontend/workspace/src/atlas/KernelPanel.test.ts b/frontend/workspace/src/atlas/KernelPanel.test.ts index 000db026..19130124 100644 --- a/frontend/workspace/src/atlas/KernelPanel.test.ts +++ b/frontend/workspace/src/atlas/KernelPanel.test.ts @@ -88,4 +88,13 @@ describe("kernel control room", () => { expect(css).toMatch(/\.compute-surface \.kernel-card\s*\{[^}]*box-shadow: none/s) expect(css).toMatch(/\.compute-surface \.kernel-card__metric\s*\{[^}]*min-height: 40px/s) }) + + test("materializes a new session and refreshes only while kernels are active", () => { + const panel = source() + + expect(panel).toContain("props.onEnsureSession?.()") + expect(panel).toContain("const sessionID = await ensureSession()") + expect(panel).toContain("summary().running === 0 && summary().queued === 0") + expect(panel).not.toContain('disabled={!params.id || params.id === "new"') + }) }) diff --git a/frontend/workspace/src/atlas/KernelPanel.tsx b/frontend/workspace/src/atlas/KernelPanel.tsx index bab2eef1..5e19a063 100644 --- a/frontend/workspace/src/atlas/KernelPanel.tsx +++ b/frontend/workspace/src/atlas/KernelPanel.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, createResource, onCleanup, type JSX } from "solid-js" +import { For, Show, createEffect, createMemo, createResource, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { useSDK } from "@/context/sdk" @@ -19,7 +19,7 @@ const time = (value: number | null) => { return `${Math.round(minutes / 60)}h ago` } -export function KernelPanel(): JSX.Element { +export function KernelPanel(props: { onEnsureSession?: () => Promise } = {}): JSX.Element { const sdk = useSDK() const params = useParams() const authority = useExecutionAuthority("kernel") @@ -64,14 +64,35 @@ export function KernelPanel(): JSX.Element { const [data, api] = createResource(load) const kernels = () => data()?.kernels ?? [] const summary = createMemo(() => summarizeKernels(kernels())) - const create = () => { - if (!params.id || params.id === "new" || !view.name.trim() || view.action) return + const ensureSession = async () => { + if (params.id && params.id !== "new") return params.id + return props.onEnsureSession?.() + } + const begin = async () => { + if (view.creating) { + setView("creating", false) + return + } + const id = await ensureSession() + if (!id) { + setView("problem", "OpenScience could not create a session for this kernel.") + return + } + setView({ creating: true, problem: "" }) + } + const create = async () => { + if (!view.name.trim() || view.action) return + const sessionID = await ensureSession() + if (!sessionID) { + setView("problem", "OpenScience could not create a session for this kernel.") + return + } setView({ action: "create", problem: "", notice: "" }) return request("/notebook/kernels", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - sessionID: params.id, + sessionID, name: view.name.trim(), language: view.language, }), @@ -128,8 +149,11 @@ export function KernelPanel(): JSX.Element { .catch((error) => setView("problem", error instanceof Error ? error.message : String(error))) .finally(() => setView("action", "")) } - const timer = setInterval(() => void api.refetch(), 1_000) - onCleanup(() => clearInterval(timer)) + createEffect(() => { + if (summary().running === 0 && summary().queued === 0) return + const timer = setInterval(() => void api.refetch(), 2_500) + onCleanup(() => clearInterval(timer)) + }) return (
@@ -149,8 +173,8 @@ export function KernelPanel(): JSX.Element { type="button" aria-label="Create named kernel" title="Create an isolated named Python or R kernel" - onClick={() => setView("creating", !view.creating)} - disabled={!params.id || params.id === "new" || !!view.action} + onClick={() => void begin()} + disabled={!!view.action} > diff --git a/frontend/workspace/src/atlas/RightPane.tsx b/frontend/workspace/src/atlas/RightPane.tsx index 0a61b756..69cdc1d7 100644 --- a/frontend/workspace/src/atlas/RightPane.tsx +++ b/frontend/workspace/src/atlas/RightPane.tsx @@ -23,7 +23,7 @@ import { artifactContext } from "@/artifacts/context" import { ArtifactInspector } from "@/artifacts/ArtifactInspector" import { StoredArtifactView } from "@/artifacts/StoredArtifactView" import { AsciiSpinner } from "@/atlas/shared/AsciiSpinner" -import { IconChevronLeft, IconX } from "@/atlas/shared/Icon" +import { IconChevronLeft, IconCollapse, IconExpand, IconX } from "@/atlas/shared/Icon" import { DEFAULT_PANE_WIDTH, MAX_PANE_WIDTH, @@ -58,6 +58,7 @@ export function RightPaneFrame(props: { modal: boolean mobile: boolean stacked: boolean + expanded?: boolean width: number onClose: () => void children: JSX.Element @@ -155,23 +156,28 @@ export function RightPaneFrame(props: { data-overlay={props.modal ? "true" : "false"} data-mobile={props.mobile ? "true" : "false"} data-stacked={props.stacked ? "true" : "false"} + data-expanded={props.expanded ? "true" : "false"} onKeyDown={onKeyDown} style={{ flex: props.modal || props.stacked ? "none" : `0 0 ${props.width}px`, - width: props.mobile + width: props.expanded ? "100vw" - : props.stacked - ? "100%" - : props.modal - ? "min(520px, calc(100vw - 48px))" - : `${props.width}px`, - height: props.stacked ? "100%" : undefined, - "min-width": props.mobile || props.stacked ? "0" : props.modal ? "360px" : `${MIN_PANE_WIDTH}px`, + : props.mobile + ? "100vw" + : props.stacked + ? "100%" + : props.modal + ? "min(520px, calc(100vw - 48px))" + : `${props.width}px`, + height: props.expanded ? "100dvh" : props.stacked ? "100%" : undefined, + "min-width": + props.expanded || props.mobile || props.stacked ? "0" : props.modal ? "360px" : `${MIN_PANE_WIDTH}px`, position: props.modal ? "fixed" : "relative", - top: props.modal ? "0" : undefined, - right: props.modal ? "0" : undefined, - bottom: props.modal ? "0" : undefined, - "z-index": props.modal ? 70 : undefined, + inset: props.expanded ? "0" : undefined, + top: props.modal && !props.expanded ? "0" : undefined, + right: props.modal && !props.expanded ? "0" : undefined, + bottom: props.modal && !props.expanded ? "0" : undefined, + "z-index": props.expanded ? 90 : props.modal ? 70 : undefined, }} > {props.children} @@ -180,7 +186,14 @@ export function RightPaneFrame(props: { ) } -export function RightPane(props: { project?: string; session?: string; route?: string } = {}): JSX.Element { +export function RightPane( + props: { + project?: string + session?: string + route?: string + onEnsureSession?: () => Promise + } = {}, +): JSX.Element { const context = uiStore.context const artifact = artifactContext.active const project = () => props.project ?? props.route ?? window.location.pathname @@ -197,6 +210,7 @@ export function RightPane(props: { project?: string; session?: string; route?: s } } const [width, setWidth] = createSignal(initial()) + const [expanded, setExpanded] = createSignal(false) const [viewport, setViewport] = createSignal(typeof window === "undefined" ? 1440 : window.innerWidth) const [narrow, setNarrow] = createSignal(typeof window !== "undefined" && window.innerWidth < INLINE_PANE_BREAKPOINT) const paneWidth = createMemo(() => paneWidthForViewport(width(), viewport())) @@ -247,11 +261,12 @@ export function RightPane(props: { project?: string; session?: string; route?: s return ( (expanded() ? setExpanded(false) : uiStore.closeContext())} > <>
+ + +
diff --git a/frontend/workspace/src/atlas/file-location-menu.test.ts b/frontend/workspace/src/atlas/file-location-menu.test.ts index 51cb49a1..0328ec2a 100644 --- a/frontend/workspace/src/atlas/file-location-menu.test.ts +++ b/frontend/workspace/src/atlas/file-location-menu.test.ts @@ -122,13 +122,13 @@ describe("Files sources", () => { const host = setup() expect(host.querySelector('[aria-label="Files sources"]')).not.toBeNull() - expect(host.querySelector("#project-folder-heading")?.textContent).toBe("Project folder") - expect(host.querySelector("#session-files-heading")?.textContent).toBe("Session files") + expect(host.querySelector("#workspace-locations-heading")?.textContent).toContain("Workspace") + expect(host.textContent).toContain("Local and session files") expect(host.querySelector("#artifacts-heading")?.textContent).toBe("Artifacts") expect(host.querySelector("#connected-locations-heading")?.textContent).toBe("Connected locations") - expect(host.querySelectorAll("section")).toHaveLength(4) + expect(host.querySelectorAll("section")).toHaveLength(3) expect(host.textContent).toContain("kras-speedrun") - expect(host.textContent).toContain("Already connected") + expect(host.textContent).toContain("Session workspace") expect(host.textContent).toContain("results.csv") expect(host.textContent).toContain("inputs") expect(host.textContent).toContain("publication") diff --git a/frontend/workspace/src/atlas/right-pane-files.test.ts b/frontend/workspace/src/atlas/right-pane-files.test.ts index f171f2f2..ee591905 100644 --- a/frontend/workspace/src/atlas/right-pane-files.test.ts +++ b/frontend/workspace/src/atlas/right-pane-files.test.ts @@ -43,7 +43,9 @@ test("preserves the center conversation for markdown links while opening Files o expect(session).toContain('aria-label="Conversation"') expect(session).toContain("uiStore.openFile(projectPath(), path)") expect(session).not.toContain("uiStore.closeFile()") - expect(session).toContain('') + expect(session).toContain( + '', + ) expect(session).toContain('document.addEventListener("openscience:open-file", onOpenFile)') expect(session).not.toContain('role="tabpanel"') expect(session).not.toContain(" { expect(source).toContain('import { ComputeSurface } from "@/atlas/ComputeSurface"') expect(source).toContain('when={context() === "kernels"}') - expect(source).toContain("") + expect(source).toContain("") expect(source).not.toContain("") }) diff --git a/frontend/workspace/src/atlas/shared/Icon.tsx b/frontend/workspace/src/atlas/shared/Icon.tsx index f84977fb..1c859bce 100644 --- a/frontend/workspace/src/atlas/shared/Icon.tsx +++ b/frontend/workspace/src/atlas/shared/Icon.tsx @@ -63,6 +63,8 @@ export const IconStar = icon("models") export const IconStarFilled = icon("models") export const IconPin = icon("pin") export const IconPinFilled = icon("pin-filled") +export const IconExpand = icon("expand") +export const IconCollapse = icon("collapse") export const IconTrash = icon("trash") export const IconShare = icon("share") export const IconDownload = icon("download") diff --git a/frontend/workspace/src/components/dialog-settings.tsx b/frontend/workspace/src/components/dialog-settings.tsx index b11b06ca..217128ac 100644 --- a/frontend/workspace/src/components/dialog-settings.tsx +++ b/frontend/workspace/src/components/dialog-settings.tsx @@ -200,12 +200,12 @@ const SETTINGS_STYLES = ` } ` -export const DialogSettings: Component = () => { +export const DialogSettings: Component<{ initial?: SettingsPanelId }> = (props) => { const platform = usePlatform() const dialog = useDialog() // Browser-style history so back/forward chevrons are real navigation. - const [history, setHistory] = createSignal([DEFAULT_PANEL]) + const [history, setHistory] = createSignal([props.initial ?? DEFAULT_PANEL]) const [cursor, setCursor] = createSignal(0) const [expanded, setExpanded] = createSignal(false) diff --git a/frontend/workspace/src/components/mobile-compose-model.test.ts b/frontend/workspace/src/components/mobile-compose-model.test.ts index 1142fc5e..a51f0def 100644 --- a/frontend/workspace/src/components/mobile-compose-model.test.ts +++ b/frontend/workspace/src/components/mobile-compose-model.test.ts @@ -13,11 +13,17 @@ describe("mobile compose and model sheets", () => { expect(prompt).not.toContain('aria-label="Composer mode"') expect(prompt).toContain('class="workspace-composer__send') expect(prompt).toContain('class="workspace-composer__overflow"') - expect(prompt).toContain("Mode: ${local.agent.current()?.name") + expect(prompt).toContain('aria-label="Research capabilities"') + expect(prompt).toContain("Capabilities") expect(prompt).toContain('aria-haspopup="menu"') expect(prompt).toContain("aria-expanded={modeOpen()}") expect(prompt).toContain("local.agent.list()") expect(prompt).toContain("local.agent.set(agent.name)") + expect(prompt).toContain('openCapability("review")') + expect(prompt).toContain('openSettings("memory")') + expect(prompt).toContain('openSettings("specialists")') + expect(prompt).toContain('openSettings("skills")') + expect(prompt).toContain('openCapability("compute")') expect(prompt).not.toContain('') expect(prompt).not.toContain("Clear attachments") expect(prompt).not.toContain("Open Terminal") diff --git a/frontend/workspace/src/components/prompt-input.css b/frontend/workspace/src/components/prompt-input.css index aea3bc08..e796b1e6 100644 --- a/frontend/workspace/src/components/prompt-input.css +++ b/frontend/workspace/src/components/prompt-input.css @@ -267,6 +267,72 @@ font-size: 11px; } +.workspace-composer__capability-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 8px 10px 5px; + border-top: 1px solid var(--border-weak-base); +} + +.workspace-composer__capability-heading:first-child { + border-top: 0; +} + +.workspace-composer__capability-heading span { + color: var(--text-strong); + font-size: 11px; + font-weight: 600; +} + +.workspace-composer__capability-heading small { + color: var(--text-weaker); + font-size: 10px; +} + +.workspace-composer__capability-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; + padding: 2px 5px 6px; +} + +.workspace-composer__capability-list button { + min-width: 0; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: start; + gap: 7px; + padding: 7px; + text-align: left; +} + +.workspace-composer__capability-list button > span { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.workspace-composer__capability-list strong { + color: var(--text-strong); + font-size: 11px; + font-weight: 600; +} + +.workspace-composer__capability-list small { + color: var(--text-weak); + font-size: 10px; + line-height: 1.3; +} + +@media (max-width: 560px) { + .workspace-composer__capability-list { + grid-template-columns: 1fr; + } +} + .workspace-composer__overflow button:hover:not(:disabled), .workspace-composer__overflow button:focus-visible { background: var(--surface-raised-base-hover); diff --git a/frontend/workspace/src/components/prompt-input.tsx b/frontend/workspace/src/components/prompt-input.tsx index a69c4e90..3f21e9c8 100644 --- a/frontend/workspace/src/components/prompt-input.tsx +++ b/frontend/workspace/src/components/prompt-input.tsx @@ -170,6 +170,20 @@ export const PromptInput: Component = (props) => { queueMicrotask(() => fileInputRef.click()) } + const openSettings = (initial: "skills" | "memory" | "specialists") => { + setModeOpen(false) + dialog.show(() => ) + } + + const openCapability = (name: "review" | "compute") => { + setModeOpen(false) + document.dispatchEvent( + new CustomEvent(name === "review" ? "openscience:run-review" : "openscience:open-context", { + detail: name === "compute" ? { context: "kernels" } : undefined, + }), + ) + } + onMount(() => { const dismiss = (event: PointerEvent) => { if (!modeOpen()) return @@ -2034,18 +2048,22 @@ export const PromptInput: Component = (props) => { > - {local.agent.current()?.name ?? "Agent"} + Capabilities -
+
+
+ Research agent + General-purpose by default +
{(agent) => ( @@ -2077,6 +2095,47 @@ export const PromptInput: Component = (props) => { )}
+
+ Tools + Open a working research surface +
+
+ + + + + +
diff --git a/frontend/workspace/src/components/settings/Memory.tsx b/frontend/workspace/src/components/settings/Memory.tsx index 65ca0c19..69ac0f16 100644 --- a/frontend/workspace/src/components/settings/Memory.tsx +++ b/frontend/workspace/src/components/settings/Memory.tsx @@ -182,10 +182,10 @@ export default function Memory() {
-

Memory

+

Research memory

- Notes and standing instructions the agent remembers across sessions. When memory is on, these are added to - the agent's context on every turn. Each scope has a character budget so memory stays small and curated. + Keep a small, private brain for your preferences and a separate brain for this project. When memory is on, + relevant notes are added to the agent's context on every turn.

@@ -196,8 +196,8 @@ export default function Memory() { @@ -227,7 +227,9 @@ export default function Memory() {
- Memory enabled + + {scope() === "global" ? "Personal memory enabled" : "Project memory enabled"} + {doc().enabled ? "Notes are recalled into agent context." : "Notes are saved but not recalled."} diff --git a/frontend/workspace/src/components/settings/Storage.tsx b/frontend/workspace/src/components/settings/Storage.tsx index adbd2e0c..35b3a9a2 100644 --- a/frontend/workspace/src/components/settings/Storage.tsx +++ b/frontend/workspace/src/components/settings/Storage.tsx @@ -123,7 +123,9 @@ export const Storage: Component = () => {

Data location

-

The directory holding sessions, credentials, skills, and logs.

+

+ The directory holding sessions, credentials, skills, and logs. The default is ~/.openscience. +

diff --git a/frontend/workspace/src/components/settings/registry.ts b/frontend/workspace/src/components/settings/registry.ts index 34964542..a1ee5457 100644 --- a/frontend/workspace/src/components/settings/registry.ts +++ b/frontend/workspace/src/components/settings/registry.ts @@ -25,6 +25,7 @@ export type SettingsSection = "inference" | "capabilities" | "runtime" | "app" export type SettingsPanelId = | "models" | "skills" + | "memory" | "connectors" | "specialists" | "compute" @@ -66,6 +67,13 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ section: "capabilities", component: lazy(() => import("./Skills")), }, + { + id: "memory", + title: "Memory", + icon: "brain", + section: "capabilities", + component: lazy(() => import("./Memory")), + }, { id: "connectors", title: "Connectors", @@ -80,9 +88,8 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ section: "capabilities", component: lazy(() => import("./Specialists")), }, - // Local models and Memory deliberately remain implemented but hidden from - // the launch UI. Local models need a real chat + tool-call + streaming - // smoke; Memory will be redesigned later from Hermes/company-brain work. + // Local models remain implemented but hidden until chat, tool-call, and + // streaming behavior pass a full runtime smoke. // ── Runtime ── { id: "compute", diff --git a/frontend/workspace/src/components/settings/truth-pass.test.ts b/frontend/workspace/src/components/settings/truth-pass.test.ts index 31ce1582..116ccd76 100644 --- a/frontend/workspace/src/components/settings/truth-pass.test.ts +++ b/frontend/workspace/src/components/settings/truth-pass.test.ts @@ -11,13 +11,14 @@ describe("launch settings truth pass", () => { expect(DEFAULT_PANEL).toBe("models") }) - test("hides deferred local models and Memory without deleting their implementations", () => { + test("keeps local models deferred and exposes the working memory implementation", () => { const ids = SETTINGS_PANELS.map((item) => item.id as string) expect(ids).not.toContain("local-models") - expect(ids).not.toContain("memory") + expect(ids).toContain("memory") expect(source("LocalModels.tsx")).toContain("const LocalModels: Component = () =>") expect(source("Memory.tsx")).toContain("export default") + expect(findPanel("memory").section).toBe("capabilities") }) test("keeps the real skills catalog in Customize rather than a work tab", () => { diff --git a/frontend/workspace/src/context/sync.tsx b/frontend/workspace/src/context/sync.tsx index 19189be8..f80e2c5d 100644 --- a/frontend/workspace/src/context/sync.tsx +++ b/frontend/workspace/src/context/sync.tsx @@ -322,6 +322,29 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, + pin: async (sessionID: string, pinned: boolean) => { + const client = sdk.client + const [, setStore] = child() + const value = pinned ? Date.now() : 0 + const previous: { value?: number } = {} + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (session) => session.id) + if (!match.found) return + previous.value = draft.session[match.index].time.pinned + draft.session[match.index].time.pinned = value || undefined + }), + ) + await client.session.update({ sessionID, time: { pinned: value } }).catch((error) => { + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (session) => session.id) + if (match.found) draft.session[match.index].time.pinned = previous.value + }), + ) + throw error + }) + }, rename: async (sessionID: string, title: string) => { const client = sdk.client const [, setStore] = child() diff --git a/frontend/workspace/src/pages/session-shell.test.ts b/frontend/workspace/src/pages/session-shell.test.ts index eb523082..a9641669 100644 --- a/frontend/workspace/src/pages/session-shell.test.ts +++ b/frontend/workspace/src/pages/session-shell.test.ts @@ -186,6 +186,17 @@ describe("focused workspace shell", () => { expect(styles).toContain("font-size: 12px") }) + test("persists sidebar collapse and session pin actions", () => { + const session = read("session.tsx") + const styles = read("../styles/atlas.css") + + expect(session).toContain("openscience-session-sidebar-v1") + expect(session).toContain("sync.session.pin(sessionID, pinned)") + expect(session).toContain('data-pinned={props.session.time?.pinned ? "true" : undefined}') + expect(session).toContain('title: "Delete this session?"') + expect(styles).toContain('.session-sidebar[data-collapsed="true"]') + }) + test("keeps research tools in a route-owned contextual surface", () => { const pane = read("../atlas/RightPane.tsx") @@ -200,9 +211,10 @@ describe("focused workspace shell", () => { expect(pane).toContain("["data"]["session"][number] * the unchanged openscience backend chat (SessionTurn rendering, PromptInput, * real SSE streaming, sub-task delegation, tool calls, TODOs, diff cards). */ +const sessionSidebarKey = "openscience-session-sidebar-v1" + +function readSessionSidebar() { + if (typeof localStorage === "undefined") return false + try { + return localStorage.getItem(sessionSidebarKey) === "collapsed" + } catch { + return false + } +} + +function writeSessionSidebar(collapsed: boolean) { + try { + localStorage.setItem(sessionSidebarKey, collapsed ? "collapsed" : "expanded") + } catch {} +} + export default function Page(): JSX.Element { const params = useParams() const navigate = useNavigate() @@ -93,7 +112,9 @@ export default function Page(): JSX.Element { const dialog = useDialog() const trust = projectTrustApi(sdk.client) const [creating, setCreating] = createSignal(false) + const pending: { value?: Promise } = {} const [mobileSessionsOpen, setMobileSessionsOpen] = createSignal(false) + const [sessionsCollapsed, setSessionsCollapsed] = createSignal(readSessionSidebar()) const [atlasConnected, setAtlasConnected] = createSignal(false) const sessionTabs = createSessionTabs() @@ -119,32 +140,41 @@ export default function Page(): JSX.Element { } async function ensureSession() { - if (creating()) return + if (params.id && params.id !== "new") return params.id + if (pending.value) return pending.value setCreating(true) - try { - const res: any = await sdk.client.session.create() - const data = res?.data ?? res - const id = data?.id ?? data?.sessionID - if (id) { + const task = sdk.client.session + .create() + .then((res) => { + const data = res.data + const id = data?.id + if (!id) return navigate(`/${params.dir}/session/${id}`) - return id as string - } else { - navigate(`/${params.dir}/session/new`) - } - } catch { - navigate(`/${params.dir}/session/new`) - } finally { - setCreating(false) - } + return id + }) + .catch(() => undefined) + .finally(() => { + pending.value = undefined + setCreating(false) + }) + pending.value = task + return task } const openContext = (context: SessionContext) => { uiStore.openContext(context) - if ((context !== "terminal" && context !== "files") || (params.id && params.id !== "new")) return + if (!(["terminal", "files", "kernels"] as SessionContext[]).includes(context)) return void ensureSession() } async function deleteSession(sessionID: string) { + const ok = await confirmDialog(dialog, { + title: "Delete this session?", + message: "This removes the conversation and its session-owned workspace. Saved artifacts stay available.", + confirmLabel: "delete session", + danger: true, + }) + if (!ok) return // Capture the next-active id BEFORE the optimistic splice so we // know where to navigate. const active = params.id === sessionID @@ -172,6 +202,18 @@ export default function Page(): JSX.Element { } } + async function pinSession(sessionID: string, pinned: boolean) { + await sync.session.pin(sessionID, pinned).catch((error: unknown) => { + toast.error("could not update pin", error instanceof Error ? error.message : String(error)) + }) + } + + function toggleSessions() { + const next = !sessionsCollapsed() + setSessionsCollapsed(next) + writeSessionSidebar(next) + } + // Force-load the session list into the sync store every time we land // on a project. sync.session.fetch() calls session.list AND reconciles // the result into the per-directory store; the raw SDK call alone @@ -231,7 +273,14 @@ export default function Page(): JSX.Element { } const projectPath = () => sdk.directory const sessions = createMemo(() => - [...sync.data.session].filter((s) => !s.parentID).sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)), + [...sync.data.session] + .filter((s) => !s.parentID) + .sort( + (a, b) => + Number(Boolean(b.time?.pinned)) - Number(Boolean(a.time?.pinned)) || + (b.time?.pinned ?? 0) - (a.time?.pinned ?? 0) || + (b.time?.updated ?? 0) - (a.time?.updated ?? 0), + ), ) createComputed( @@ -308,7 +357,19 @@ export default function Page(): JSX.Element { uiStore.openFile(projectPath(), path) } document.addEventListener("openscience:open-file", onOpenFile) - onCleanup(() => document.removeEventListener("openscience:open-file", onOpenFile)) + const onOpenContext = (event: Event) => { + const context = (event as CustomEvent).detail?.context + if (!(["files", "terminal", "canvas", "kernels", "trace"] as SessionContext[]).includes(context)) return + openContext(context) + } + const onReview = () => void runReview() + document.addEventListener("openscience:open-context", onOpenContext) + document.addEventListener("openscience:run-review", onReview) + onCleanup(() => { + document.removeEventListener("openscience:open-file", onOpenFile) + document.removeEventListener("openscience:open-context", onOpenContext) + document.removeEventListener("openscience:run-review", onReview) + }) }) const turnMessages = createMemo(() => { const revertID = revertInfo()?.messageID @@ -619,12 +680,14 @@ export default function Page(): JSX.Element { activeId={params.id} dirParam={params.dir ?? ""} creating={creating()} + collapsed={sessionsCollapsed()} mobileOpen={mobileSessionsOpen()} onNew={() => { setMobileSessionsOpen(false) newSession() }} onBack={() => navigate("/")} + onCollapse={toggleSessions} onSearch={() => { setMobileSessionsOpen(false) uiStore.setPaletteOpen(true) @@ -648,6 +711,7 @@ export default function Page(): JSX.Element { }} onDelete={(id) => void deleteSession(id)} onRename={(id, title) => void renameSession(id, title)} + onPin={(id, pinned) => void pinSession(id, pinned)} />
- +
) @@ -1294,9 +1358,11 @@ function SessionsSidebar(props: { activeId: string | undefined dirParam: string creating: boolean + collapsed: boolean mobileOpen: boolean onNew: () => void onBack: () => void + onCollapse: () => void onSearch: () => void onCustomize: () => void onContext: (context: SessionContext) => void @@ -1307,11 +1373,13 @@ function SessionsSidebar(props: { onSelect: (id: string) => void onDelete: (id: string) => void onRename: (id: string, title: string) => void + onPin: (id: string, pinned: boolean) => void }): JSX.Element { return (
) } diff --git a/frontend/workspace/src/styles/atlas.css b/frontend/workspace/src/styles/atlas.css index 043f9197..f8e9d95f 100644 --- a/frontend/workspace/src/styles/atlas.css +++ b/frontend/workspace/src/styles/atlas.css @@ -5822,6 +5822,9 @@ button.research-launchpad__status-item:hover { .session-sidebar__top { padding: 5px 5px 2px; border-bottom: 0; + display: flex; + align-items: center; + gap: 2px; } .session-sidebar__project { @@ -5839,6 +5842,29 @@ button.research-launchpad__status-item:hover { cursor: pointer; } +.session-sidebar__collapse { + all: unset; + box-sizing: border-box; + width: 28px; + height: 28px; + flex: 0 0 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--color-text-faint); + cursor: pointer; + transition: + background 140ms ease, + color 140ms ease, + transform 180ms ease; +} + +.session-sidebar__collapse:hover { + background: var(--color-accent-subtle); + color: var(--color-text); +} + .session-sidebar__project:hover { background: var(--color-accent-subtle); color: var(--color-text); @@ -5932,7 +5958,7 @@ button.research-launchpad__status-item:hover { font-size: 11.5px; } -.session-sidebar__session-delete { +.session-sidebar__session-menu-button { all: unset; box-sizing: border-box; position: absolute; @@ -5949,11 +5975,116 @@ button.research-launchpad__status-item:hover { transform: translateY(-50%); } -.session-sidebar__session-delete:hover { - background: var(--color-error-muted); +.session-sidebar__session-menu-button:hover { + background: var(--color-accent-subtle); + color: var(--color-text); +} + +.session-sidebar__session-menu { + position: absolute; + z-index: 12; + top: calc(100% - 2px); + right: 3px; + min-width: 132px; + padding: 4px; + border: 1px solid var(--color-border); + border-radius: 7px; + background: var(--color-bg-elevated); + box-shadow: 0 10px 26px color-mix(in srgb, var(--color-bg) 36%, transparent); +} + +.session-sidebar__session-menu button { + all: unset; + box-sizing: border-box; + width: 100%; + min-height: 28px; + display: flex; + align-items: center; + gap: 7px; + padding: 5px 7px; + border-radius: 5px; + color: var(--color-text-muted); + font-size: 12px; + cursor: pointer; +} + +.session-sidebar__session-menu button:hover, +.session-sidebar__session-menu button:focus-visible { + background: var(--color-accent-subtle); + color: var(--color-text); +} + +.session-sidebar__session-menu button[data-danger="true"] { color: var(--color-error); } +@media (min-width: 720px) { + .session-sidebar { + transition: + width 180ms cubic-bezier(0.16, 1, 0.3, 1), + min-width 180ms cubic-bezier(0.16, 1, 0.3, 1), + margin 180ms cubic-bezier(0.16, 1, 0.3, 1); + } + + .session-sidebar[data-collapsed="true"] { + width: 48px; + min-width: 48px; + margin-right: 7px; + overflow: visible; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__project { + width: 0; + min-width: 0; + padding: 0; + overflow: hidden; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__collapse { + transform: rotate(180deg); + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__actions { + padding-inline: 7px; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__group-label, + .session-sidebar[data-collapsed="true"] .session-sidebar__action-copy, + .session-sidebar[data-collapsed="true"] .session-sidebar__action kbd, + .session-sidebar[data-collapsed="true"] .session-sidebar__label, + .session-sidebar[data-collapsed="true"] .session-sidebar__session-title, + .session-sidebar[data-collapsed="true"] .session-sidebar__empty { + display: none; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__action, + .session-sidebar[data-collapsed="true"] .session-sidebar__new { + width: 32px; + min-width: 32px; + padding: 0; + justify-content: center; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__list { + padding-inline: 7px; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__session { + width: 32px; + min-height: 30px; + justify-content: center; + padding: 0; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__session[data-actions="true"] { + padding: 0; + } + + .session-sidebar[data-collapsed="true"] .session-sidebar__session-menu-button { + display: none; + } +} + .session-right-pane { margin-left: 0; overflow: hidden; diff --git a/tooling/launcher/bin/synsci.mjs b/tooling/launcher/bin/synsci.mjs index 7b37deae..d50635d0 100755 --- a/tooling/launcher/bin/synsci.mjs +++ b/tooling/launcher/bin/synsci.mjs @@ -199,9 +199,22 @@ function hasDeprecatedCli() { function isConnected() { const explicit = process.env.OPENSCIENCE_DATA_DIR?.trim() const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") - const data = explicit ? resolve(explicit) : join(xdgData, "openscience") - const sessionPath = join(data, "openscience-session.json") - if (!existsSync(sessionPath)) return false + const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") + const pointerPath = join(xdgConfig, "openscience", "data-location") + const pointer = (() => { + try { + return readFileSync(pointerPath, "utf-8").trim() + } catch { + return "" + } + })() + const roots = explicit + ? [resolve(explicit)] + : pointer + ? [resolve(pointer)] + : [join(homedir(), ".openscience"), join(xdgData, "openscience")] + const sessionPath = roots.map((root) => join(root, "openscience-session.json")).find(existsSync) + if (!sessionPath) return false try { const data = JSON.parse(readFileSync(sessionPath, "utf-8")) if (!data.access_token || !data.expires_at) return false diff --git a/tooling/sdk/js/src/v2/gen/sdk.gen.ts b/tooling/sdk/js/src/v2/gen/sdk.gen.ts index 9ff72838..d8cea4d7 100644 --- a/tooling/sdk/js/src/v2/gen/sdk.gen.ts +++ b/tooling/sdk/js/src/v2/gen/sdk.gen.ts @@ -276,6 +276,7 @@ import type { SettingsStorageRelocateResponses, SettingsStorageResetLocationResponses, SettingsStorageUsageResponses, + SettingsUpdatesCheckResponses, SettingsUsageGetResponses, SettingsWalletGetResponses, SubtaskPartInput, @@ -344,15 +345,29 @@ export class Project extends HeyApiClient { /** * Create project * - * Create an app-managed project with an opaque identity. The server owns its local root; clients provide a display name, never a host path. + * Create an app-managed project with an opaque identity and optional project-scoped access to source locations explicitly selected by the user. Source paths never become the project identity. */ public create( parameters?: { name?: string + sources?: Array<{ + path: string + access?: "read" | "write" + }> }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "name" }] }]) + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "name" }, + { in: "body", key: "sources" }, + ], + }, + ], + ) return (options?.client ?? this.client).post( { url: "/global/project", @@ -1263,6 +1278,18 @@ export class Wallet extends HeyApiClient { } } +export class Updates extends HeyApiClient { + /** + * Check for an OpenScience update + */ + public check(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/settings/updates", + ...options, + }) + } +} + export class Skills extends HeyApiClient { /** * Install skill from git @@ -1536,6 +1563,11 @@ export class Settings extends HeyApiClient { return (this._wallet ??= new Wallet({ client: this.client })) } + private _updates?: Updates + get updates(): Updates { + return (this._updates ??= new Updates({ client: this.client })) + } + private _skills?: Skills get skills(): Skills { return (this._skills ??= new Skills({ client: this.client })) @@ -2588,6 +2620,7 @@ export class Session extends HeyApiClient { title?: string time?: { archived?: number + pinned?: number } }, options?: Options, diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index 0561b2e7..a87ec50e 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -826,6 +826,7 @@ export type Session = { updated: number compacting?: number archived?: number + pinned?: number } permission?: PermissionRuleset revert?: { @@ -2416,6 +2417,10 @@ export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthRespo export type GlobalProjectCreateData = { body?: { name: string + sources?: Array<{ + path: string + access?: "read" | "write" + }> } path?: never query?: never @@ -5226,6 +5231,29 @@ export type SettingsWalletGetResponses = { export type SettingsWalletGetResponse = SettingsWalletGetResponses[keyof SettingsWalletGetResponses] +export type SettingsUpdatesCheckData = { + body?: never + path?: never + query?: never + url: "/settings/updates" +} + +export type SettingsUpdatesCheckResponses = { + /** + * Current and latest package versions + */ + 200: { + current: string + latest: string + channel: string + method: string + updateAvailable: boolean + releaseNotes: string + } +} + +export type SettingsUpdatesCheckResponse = SettingsUpdatesCheckResponses[keyof SettingsUpdatesCheckResponses] + export type AuthRemoveData = { body?: never path: { @@ -6144,6 +6172,7 @@ export type SessionUpdateData = { title?: string time?: { archived?: number + pinned?: number } } path: { diff --git a/tooling/sdk/openapi.json b/tooling/sdk/openapi.json index e166e38c..4e3f7d48 100644 --- a/tooling/sdk/openapi.json +++ b/tooling/sdk/openapi.json @@ -48,7 +48,7 @@ "post": { "operationId": "global.project.create", "summary": "Create project", - "description": "Create an app-managed project with an opaque identity. The server owns its local root; clients provide a display name, never a host path.", + "description": "Create an app-managed project with an opaque identity and optional project-scoped access to source locations explicitly selected by the user. Source paths never become the project identity.", "responses": { "201": { "description": "Created project information", @@ -79,6 +79,31 @@ "properties": { "name": { "type": "string" + }, + "sources": { + "default": [], + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "access": { + "default": "write", + "type": "string", + "enum": [ + "read", + "write" + ] + } + }, + "required": [ + "path" + ] + } } }, "required": [ @@ -8774,6 +8799,59 @@ ] } }, + "/settings/updates": { + "get": { + "operationId": "settings.updates.check", + "summary": "Check for an OpenScience update", + "responses": { + "200": { + "description": "Current and latest package versions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "latest": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "method": { + "type": "string" + }, + "updateAvailable": { + "type": "boolean" + }, + "releaseNotes": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "current", + "latest", + "channel", + "method", + "updateAvailable", + "releaseNotes" + ] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpenScienceClient } from \"@synsci/sdk\"\n\nconst client = createOpenScienceClient()\nawait client.settings.updates.check({\n ...\n})" + } + ] + } + }, "/auth/{providerID}": { "put": { "operationId": "auth.set", @@ -10828,6 +10906,9 @@ "properties": { "archived": { "type": "number" + }, + "pinned": { + "type": "number" } } } @@ -30987,6 +31068,9 @@ }, "archived": { "type": "number" + }, + "pinned": { + "type": "number" } }, "required": [ From 7bb9cca0a2372f1d788c50cd2bba73de6920eb69 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 2 Aug 2026 18:34:21 +0800 Subject: [PATCH 2/7] fix published workspace QA regressions --- frontend/workspace/e2e/session.spec.ts | 34 ++++++++++++++++++- .../components/mobile-compose-model.test.ts | 2 ++ .../workspace/src/components/prompt-input.css | 5 ++- .../workspace/src/pages/session-shell.test.ts | 2 ++ frontend/workspace/src/pages/session.tsx | 13 ++++++- 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/frontend/workspace/e2e/session.spec.ts b/frontend/workspace/e2e/session.spec.ts index 071367ae..86013660 100644 --- a/frontend/workspace/e2e/session.spec.ts +++ b/frontend/workspace/e2e/session.spec.ts @@ -69,7 +69,11 @@ test("session lifecycle works through the sidebar UI", async ({ page, slug, sdk, await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:\\?|#|$)`)) await renamedRow.hover() - const deleteButton = renamedRow.getByRole("button", { name: "delete session" }) + const actions = renamedRow.getByRole("button", { name: "Session actions", exact: true }) + await expect(actions).toBeVisible() + await actions.click() + await renamedRow.getByRole("menuitem", { name: "Delete", exact: true }).click() + const deleteButton = page.getByRole("button", { name: "delete session", exact: true }) await expect(deleteButton).toBeVisible() const deleteResponsePromise = page.waitForResponse((response) => isSessionResponse(response, "DELETE", sessionID)) await deleteButton.click() @@ -92,3 +96,31 @@ test("session lifecycle works through the sidebar UI", async ({ page, slug, sdk, if (sessionID) await sdk.session.delete({ sessionID }).catch(() => undefined) } }) + +test("opening Compute from a new route creates a durable session and keeps the surface open", async ({ + page, + slug, + sdk, + openSession, +}) => { + let sessionID: string | undefined + + try { + await openSession() + await page.getByRole("button", { name: "New research", exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session/new(?:\\?|#|$)`)) + + const created = page.waitForResponse((response) => isSessionResponse(response, "POST")) + await page.getByRole("button", { name: "Open session compute", exact: true }).click() + const response = await created + const session = (await response.json()) as { id?: string } + if (!session.id) throw new Error("Compute session creation returned no id") + sessionID = session.id + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:\\?|#|$)`)) + await expect(page.getByRole("region", { name: "Compute", exact: true })).toBeVisible() + await expect(page.getByRole("tab", { name: "Kernels", exact: true })).toHaveAttribute("aria-selected", "true") + } finally { + if (sessionID) await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/frontend/workspace/src/components/mobile-compose-model.test.ts b/frontend/workspace/src/components/mobile-compose-model.test.ts index a51f0def..de64e8f8 100644 --- a/frontend/workspace/src/components/mobile-compose-model.test.ts +++ b/frontend/workspace/src/components/mobile-compose-model.test.ts @@ -40,6 +40,8 @@ describe("mobile compose and model sheets", () => { expect(css).toContain("overflow: visible") expect(css).not.toContain("overflow-x: auto") expect(css).toContain(".workspace-composer__overflow > div") + expect(css).toContain("width: min(390px, calc(100vw - 24px))") + expect(css).toContain("white-space: normal") expect(css).not.toContain("mobile-compose-sheet") }) diff --git a/frontend/workspace/src/components/prompt-input.css b/frontend/workspace/src/components/prompt-input.css index e796b1e6..bd4a343e 100644 --- a/frontend/workspace/src/components/prompt-input.css +++ b/frontend/workspace/src/components/prompt-input.css @@ -185,7 +185,7 @@ z-index: 70; left: 0; bottom: calc(100% + 6px); - width: min(280px, calc(100vw - 24px)); + width: min(390px, calc(100vw - 24px)); max-height: min(320px, calc(100dvh - 160px)); overflow-y: auto; padding: 5px; @@ -305,7 +305,10 @@ align-items: start; gap: 7px; padding: 7px; + overflow: visible; text-align: left; + text-overflow: clip; + white-space: normal; } .workspace-composer__capability-list button > span { diff --git a/frontend/workspace/src/pages/session-shell.test.ts b/frontend/workspace/src/pages/session-shell.test.ts index a9641669..c82d0d89 100644 --- a/frontend/workspace/src/pages/session-shell.test.ts +++ b/frontend/workspace/src/pages/session-shell.test.ts @@ -192,8 +192,10 @@ describe("focused workspace shell", () => { expect(session).toContain("openscience-session-sidebar-v1") expect(session).toContain("sync.session.pin(sessionID, pinned)") + expect(session).toContain('aria-label={props.session.title || "session"}') expect(session).toContain('data-pinned={props.session.time?.pinned ? "true" : undefined}') expect(session).toContain('title: "Delete this session?"') + expect(session).toContain("uiStore.activateScope(sdk.scope, id)") expect(styles).toContain('.session-sidebar[data-collapsed="true"]') }) diff --git a/frontend/workspace/src/pages/session.tsx b/frontend/workspace/src/pages/session.tsx index 7132b42e..b4898439 100644 --- a/frontend/workspace/src/pages/session.tsx +++ b/frontend/workspace/src/pages/session.tsx @@ -112,7 +112,7 @@ export default function Page(): JSX.Element { const dialog = useDialog() const trust = projectTrustApi(sdk.client) const [creating, setCreating] = createSignal(false) - const pending: { value?: Promise } = {} + const pending: { value?: Promise; context?: SessionContext } = {} const [mobileSessionsOpen, setMobileSessionsOpen] = createSignal(false) const [sessionsCollapsed, setSessionsCollapsed] = createSignal(readSessionSidebar()) const [atlasConnected, setAtlasConnected] = createSignal(false) @@ -141,6 +141,10 @@ 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)) { + pending.context = context as SessionContext + } if (pending.value) return pending.value setCreating(true) const task = sdk.client.session @@ -149,12 +153,18 @@ export default function Page(): JSX.Element { const data = res.data const id = data?.id if (!id) return + const context = pending.context + if (context) { + uiStore.activateScope(sdk.scope, id) + uiStore.openContext(context) + } navigate(`/${params.dir}/session/${id}`) return id }) .catch(() => undefined) .finally(() => { pending.value = undefined + pending.context = undefined setCreating(false) }) pending.value = task @@ -1497,6 +1507,7 @@ function SessionRow(props: { class="session-sidebar__session" role="button" tabindex="0" + aria-label={props.session.title || "session"} data-active={props.active ? "true" : undefined} data-pinned={props.session.time?.pinned ? "true" : undefined} data-actions={hover() && !editing() ? "true" : undefined} From 4856282335ba7be771d834065be447028cfdb370 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 2 Aug 2026 18:54:51 +0800 Subject: [PATCH 3/7] polish inspector and mobile capabilities --- frontend/workspace/src/atlas/RightPane.tsx | 4 +++- frontend/workspace/src/atlas/right-pane-surface.test.ts | 3 +++ .../workspace/src/components/mobile-compose-model.test.ts | 2 ++ frontend/workspace/src/components/prompt-input.css | 8 ++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/workspace/src/atlas/RightPane.tsx b/frontend/workspace/src/atlas/RightPane.tsx index 69cdc1d7..62bb4ae3 100644 --- a/frontend/workspace/src/atlas/RightPane.tsx +++ b/frontend/workspace/src/atlas/RightPane.tsx @@ -71,14 +71,16 @@ export function RightPaneFrame(props: { createEffect(() => { if (!props.modal) { + const prior = refs.modal ? refs.prior : undefined refs.modal = false refs.prior = undefined + if (prior?.isConnected) queueMicrotask(() => prior.focus()) return } if (refs.modal) return refs.modal = true const active = document.activeElement - refs.prior = active instanceof HTMLElement && !refs.pane?.contains(active) ? active : undefined + refs.prior = active instanceof HTMLElement ? active : undefined queueMicrotask(() => { if (!refs.modal || !refs.pane) return const initial = diff --git a/frontend/workspace/src/atlas/right-pane-surface.test.ts b/frontend/workspace/src/atlas/right-pane-surface.test.ts index 7d989c54..1893139c 100644 --- a/frontend/workspace/src/atlas/right-pane-surface.test.ts +++ b/frontend/workspace/src/atlas/right-pane-surface.test.ts @@ -43,6 +43,9 @@ test("uses an inline desktop pane and a full-width narrow overlay, never a pane expect(source).toContain("modal={narrow() || expanded()}") expect(source).toContain("mobile={narrow()}") expect(source).toContain("stacked={false}") + expect(source).toContain("refs.prior = active instanceof HTMLElement ? active : undefined") + expect(source).toContain("const prior = refs.modal ? refs.prior : undefined") + expect(source).toContain("if (prior?.isConnected) queueMicrotask(() => prior.focus())") expect(styles).not.toContain('.session-right-pane[data-stacked="true"]') expect(styles).not.toContain("grid-template-rows: minmax(0, 45fr) minmax(0, 55fr)") }) diff --git a/frontend/workspace/src/components/mobile-compose-model.test.ts b/frontend/workspace/src/components/mobile-compose-model.test.ts index de64e8f8..b5ef9f47 100644 --- a/frontend/workspace/src/components/mobile-compose-model.test.ts +++ b/frontend/workspace/src/components/mobile-compose-model.test.ts @@ -41,6 +41,8 @@ describe("mobile compose and model sheets", () => { expect(css).not.toContain("overflow-x: auto") expect(css).toContain(".workspace-composer__overflow > div") expect(css).toContain("width: min(390px, calc(100vw - 24px))") + expect(css).toContain("left: -44px") + expect(css).toContain("max-height: min(440px, calc(100dvh - 140px))") expect(css).toContain("white-space: normal") expect(css).not.toContain("mobile-compose-sheet") }) diff --git a/frontend/workspace/src/components/prompt-input.css b/frontend/workspace/src/components/prompt-input.css index bd4a343e..b8ab4c82 100644 --- a/frontend/workspace/src/components/prompt-input.css +++ b/frontend/workspace/src/components/prompt-input.css @@ -255,6 +255,9 @@ color: var(--text-weak); font-size: 10.5px; font-weight: 400; + overflow: visible; + text-overflow: clip; + white-space: normal; } .workspace-composer__agent-list button[aria-checked="true"] { @@ -331,6 +334,11 @@ } @media (max-width: 560px) { + .workspace-composer__overflow > div { + left: -44px; + max-height: min(440px, calc(100dvh - 140px)); + } + .workspace-composer__capability-list { grid-template-columns: 1fr; } From 8679b34ec47abe39059c5d299a1ad6e647176bdc Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 2 Aug 2026 19:22:11 +0800 Subject: [PATCH 4/7] refine composer controls and hide trace by default --- .../src/server/routes/settings/preferences.ts | 3 + .../test/server/settings-preferences.test.ts | 21 ++ .../components/mobile-compose-model.test.ts | 29 ++- .../src/components/model-settings-popover.css | 66 +++--- .../src/components/model-settings-popover.tsx | 8 +- .../src/components/model-surface.test.ts | 7 +- .../workspace/src/components/prompt-input.css | 199 +++++++----------- .../workspace/src/components/prompt-input.tsx | 187 +++++++++------- .../src/components/settings/General.tsx | 25 ++- .../src/context/product-preferences.ts | 14 ++ .../src/pages/session-sidebar-action.test.tsx | 27 +++ .../src/pages/session-sidebar-action.tsx | 42 ++-- frontend/workspace/src/pages/session.tsx | 22 ++ tooling/sdk/js/src/v2/gen/sdk.gen.ts | 4 +- tooling/sdk/js/src/v2/gen/types.gen.ts | 3 + tooling/sdk/openapi.json | 14 +- 16 files changed, 399 insertions(+), 272 deletions(-) create mode 100644 backend/cli/test/server/settings-preferences.test.ts create mode 100644 frontend/workspace/src/context/product-preferences.ts diff --git a/backend/cli/src/server/routes/settings/preferences.ts b/backend/cli/src/server/routes/settings/preferences.ts index 281608dc..0803140c 100644 --- a/backend/cli/src/server/routes/settings/preferences.ts +++ b/backend/cli/src/server/routes/settings/preferences.ts @@ -23,6 +23,9 @@ export const Preferences = z.object({ // Soft managed-compute spend ceiling in USD the user sets for themselves // (Usage → Extra usage budget). 0 = no extra budget beyond the plan. extra_budget_usd: z.number().min(0).default(0), + // The session trace is an advanced observability surface. Keep the regular + // workspace quiet unless the user explicitly enables it in General. + show_trace: z.boolean().default(false), }) export type Preferences = z.infer diff --git a/backend/cli/test/server/settings-preferences.test.ts b/backend/cli/test/server/settings-preferences.test.ts new file mode 100644 index 00000000..0fbb6ac1 --- /dev/null +++ b/backend/cli/test/server/settings-preferences.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { Preferences, SettingsPreferencesRoutes } from "../../src/server/routes/settings/preferences" + +test("trace navigation is opt-in by default", () => { + expect(Preferences.parse({}).show_trace).toBe(false) +}) + +test("trace navigation preference persists through the settings route", async () => { + const app = SettingsPreferencesRoutes() + const update = await app.request("/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ show_trace: true }), + }) + expect(update.status).toBe(200) + expect(((await update.json()) as Preferences).show_trace).toBe(true) + + const read = await app.request("/") + expect(read.status).toBe(200) + expect(((await read.json()) as Preferences).show_trace).toBe(true) +}) diff --git a/frontend/workspace/src/components/mobile-compose-model.test.ts b/frontend/workspace/src/components/mobile-compose-model.test.ts index b5ef9f47..9d2b2503 100644 --- a/frontend/workspace/src/components/mobile-compose-model.test.ts +++ b/frontend/workspace/src/components/mobile-compose-model.test.ts @@ -14,16 +14,26 @@ describe("mobile compose and model sheets", () => { expect(prompt).toContain('class="workspace-composer__send') expect(prompt).toContain('class="workspace-composer__overflow"') expect(prompt).toContain('aria-label="Research capabilities"') - expect(prompt).toContain("Capabilities") + expect(prompt).toContain('class="workspace-composer__capability-switch"') expect(prompt).toContain('aria-haspopup="menu"') expect(prompt).toContain("aria-expanded={modeOpen()}") - expect(prompt).toContain("local.agent.list()") - expect(prompt).toContain("local.agent.set(agent.name)") - expect(prompt).toContain('openCapability("review")') - expect(prompt).toContain('openSettings("memory")') + expect(prompt).not.toContain("local.agent.list()") + expect(prompt).not.toContain("local.agent.set(agent.name)") + expect(prompt).not.toContain("agent.model") + expect(prompt).toContain("Delegation") + expect(prompt).toContain('role="menuitemcheckbox"') + expect(prompt).toContain("onClick={toggleReview}") + expect(prompt).toContain("onClick={toggleMemory}") + expect(prompt).toContain("Reviewer model") + expect(prompt).toContain("Memory") + expect(prompt).toContain("Specialist") + expect(prompt).toContain("Compute") expect(prompt).toContain('openSettings("specialists")') - expect(prompt).toContain('openSettings("skills")') - expect(prompt).toContain('openCapability("compute")') + expect(prompt).toContain('"/settings/memory?scope=global"') + expect(prompt).toContain("onClick={openCompute}") + expect(prompt).not.toContain("Review now") + expect(prompt).not.toContain("Research agent") + expect(prompt).not.toContain("General-purpose by default") expect(prompt).not.toContain('') expect(prompt).not.toContain("Clear attachments") expect(prompt).not.toContain("Open Terminal") @@ -40,10 +50,11 @@ describe("mobile compose and model sheets", () => { expect(css).toContain("overflow: visible") expect(css).not.toContain("overflow-x: auto") expect(css).toContain(".workspace-composer__overflow > div") - expect(css).toContain("width: min(390px, calc(100vw - 24px))") + expect(css).toContain("width: min(258px, calc(100vw - 24px))") expect(css).toContain("left: -44px") expect(css).toContain("max-height: min(440px, calc(100dvh - 140px))") - expect(css).toContain("white-space: normal") + expect(css).toContain(".workspace-composer__capability-divider") + expect(css).not.toContain("grid-template-columns: repeat(2") expect(css).not.toContain("mobile-compose-sheet") }) diff --git a/frontend/workspace/src/components/model-settings-popover.css b/frontend/workspace/src/components/model-settings-popover.css index 82d78de8..d3d7d6b0 100644 --- a/frontend/workspace/src/components/model-settings-popover.css +++ b/frontend/workspace/src/components/model-settings-popover.css @@ -13,7 +13,7 @@ --model-control-text: var(--text-strong); --model-control-muted: var(--text-weak); --model-control-faint: var(--text-weaker); - --model-control-shadow: 0 8px 24px color-mix(in srgb, #241f1a 12%, transparent); + --model-control-shadow: 0 16px 42px color-mix(in srgb, #241f1a 18%, transparent); font-family: var( --font-family-sans, "Inter Variable", @@ -38,20 +38,20 @@ html[data-color-scheme="dark"] --model-control-text: #f2f1ec; --model-control-muted: #b6b5ae; --model-control-faint: #8f8e87; - --model-control-shadow: 0 10px 28px #00000038, 0 2px 6px #0000001f; + --model-control-shadow: 0 18px 46px #0000004a, 0 2px 8px #00000029; } [data-model-settings-trigger-style="label"] { - min-height: 30px !important; - max-width: min(210px, 40vw); + min-height: 36px !important; + max-width: min(230px, 40vw); gap: 6px !important; - padding: 0 7px !important; - border: 0 !important; - border-radius: 7px !important; - background: transparent !important; + padding: 0 11px !important; + border: 1px solid var(--model-control-border) !important; + border-radius: 10px !important; + background: var(--model-control-raised) !important; box-shadow: none !important; color: var(--model-control-text) !important; - font-size: 13px !important; + font-size: 13.5px !important; font-weight: 500 !important; letter-spacing: -0.005em; } @@ -62,6 +62,11 @@ html[data-color-scheme="dark"] background: var(--model-control-hover) !important; } +[data-model-settings-trigger-style="label"]:focus-visible { + outline: 2px solid #4f8cff !important; + outline-offset: 1px; +} + [data-model-source-label] { color: var(--model-control-muted); font-size: 11px; @@ -92,20 +97,20 @@ html[data-color-scheme="dark"] } [data-model-settings-popover] { - width: min(274px, calc(100vw - 24px)) !important; - padding: 6px !important; + width: min(320px, calc(100vw - 24px)) !important; + padding: 8px !important; border: 1px solid var(--model-control-border-strong) !important; - border-radius: 12px !important; + border-radius: 14px !important; background: var(--model-control-surface) !important; box-shadow: var(--model-control-shadow) !important; color: var(--model-control-text); } [data-model-settings-popover] .model-settings-row { - min-height: 34px; - gap: 10px; - padding: 0 9px; - border-radius: 7px; + min-height: 40px; + gap: 12px; + padding: 0 12px; + border-radius: 9px; color: var(--model-control-text); font-size: 12px; line-height: 1.2; @@ -116,14 +121,16 @@ html[data-color-scheme="dark"] background: var(--model-control-hover); } -[data-model-settings-popover] .model-settings-row[aria-checked="true"] { - background: var(--model-control-selected); +[data-model-settings-popover] .model-settings-row:focus-visible { + outline: 1px solid #4f8cff !important; + outline-offset: -1px; + box-shadow: none !important; } [data-model-menu-label] { min-width: 0; color: var(--model-control-text); - font-size: 12px; + font-size: 13.5px; font-weight: 500; letter-spacing: -0.006em; } @@ -134,7 +141,7 @@ html[data-color-scheme="dark"] } [data-model-settings-popover] [data-model-quick] { - min-height: 46px; + min-height: 58px; } .model-settings-model { @@ -142,7 +149,7 @@ html[data-color-scheme="dark"] display: flex; flex: 1; flex-direction: column; - gap: 2px; + gap: 3px; } .model-settings-model strong, @@ -155,38 +162,39 @@ html[data-color-scheme="dark"] .model-settings-model strong { color: var(--model-control-text); - font-size: 12.5px; - font-weight: 500; + font-size: 14px; + font-weight: 520; letter-spacing: -0.005em; } .model-settings-model small { color: var(--model-control-faint); - font-size: 10.5px; + font-size: 12px; font-weight: 400; } .model-settings-more { - color: var(--model-control-muted) !important; + min-height: 46px !important; + color: var(--model-control-text) !important; } [data-model-menu-value] { max-width: 158px; gap: 8px; color: var(--model-control-muted); - font-size: 12px; + font-size: 12.5px; font-weight: 400; } .model-settings-divider { height: 1px; - margin: 4px 8px; + margin: 6px 10px; background: var(--model-control-border); } .model-settings-check { - color: var(--model-control-text); - font-size: 12px; + flex: 0 0 auto; + color: #4f8cff; } @media (max-width: 719px) { diff --git a/frontend/workspace/src/components/model-settings-popover.tsx b/frontend/workspace/src/components/model-settings-popover.tsx index 9ba4e075..e404c945 100644 --- a/frontend/workspace/src/components/model-settings-popover.tsx +++ b/frontend/workspace/src/components/model-settings-popover.tsx @@ -133,9 +133,7 @@ export const ModelOptionList: Component = (props) => { > {option.label} - + )} @@ -376,9 +374,7 @@ export const ModelSettingsPopover: Component<{ trigger?: "label" | "icon" }> = ( - + ) diff --git a/frontend/workspace/src/components/model-surface.test.ts b/frontend/workspace/src/components/model-surface.test.ts index 39508536..89d890e2 100644 --- a/frontend/workspace/src/components/model-surface.test.ts +++ b/frontend/workspace/src/components/model-surface.test.ts @@ -63,9 +63,10 @@ describe("model control surface", () => { expect(settings).toContain("modelSummary") expect(settings).toContain("More models") expect(settings).toContain("data-model-menu-value") - expect(styles).toContain("width: min(274px, calc(100vw - 24px))") - expect(styles).toContain("min-height: 46px") - expect(styles).toContain("font-size: 12.5px") + expect(styles).toContain("width: min(320px, calc(100vw - 24px))") + expect(styles).toContain("min-height: 58px") + expect(styles).toContain("font-size: 14px") + expect(styles).toContain("color: #4f8cff") expect(styles).toContain("--model-control-surface: #30302d") expect(styles).toContain("font-family: var(") diff --git a/frontend/workspace/src/components/prompt-input.css b/frontend/workspace/src/components/prompt-input.css index b8ab4c82..e4b8e9a7 100644 --- a/frontend/workspace/src/components/prompt-input.css +++ b/frontend/workspace/src/components/prompt-input.css @@ -143,31 +143,24 @@ } .workspace-composer__overflow > summary { - min-width: 32px; - height: 30px; + position: relative; + width: 36px; + height: 36px; display: inline-flex; align-items: center; justify-content: center; - gap: 5px; - padding: 0 7px; - border-radius: 7px; + padding: 0; + border: 1px solid var(--border-weak-base); + border-radius: 10px; + background: var(--surface-raised-strong); color: var(--text-weak); - font-size: 12px; - font-weight: 500; - text-transform: capitalize; cursor: pointer; list-style: none; } .workspace-composer__overflow > summary [data-slot="icon-svg"] { - width: 14px; - height: 14px; -} - -.workspace-composer__overflow-caret { - margin-left: -1px; - color: var(--text-weaker); - font-size: 10px; + width: 17px; + height: 17px; } .workspace-composer__overflow > summary::-webkit-details-marker { @@ -180,157 +173,111 @@ color: var(--text-strong); } +.workspace-composer__overflow > summary:focus-visible { + outline: 2px solid #4f8cff !important; + outline-offset: 1px; + box-shadow: none !important; +} + .workspace-composer__overflow > div { position: absolute; z-index: 70; left: 0; bottom: calc(100% + 6px); - width: min(390px, calc(100vw - 24px)); - max-height: min(320px, calc(100dvh - 160px)); + width: min(258px, calc(100vw - 24px)); + max-height: min(360px, calc(100dvh - 160px)); overflow-y: auto; - padding: 5px; - border: 1px solid var(--border-weak-base); - border-radius: 8px; + padding: 6px; + border: 1px solid var(--border-base); + border-radius: 13px; background: var(--surface-raised-stronger-non-alpha); - box-shadow: 0 8px 24px color-mix(in srgb, #000 14%, transparent); + box-shadow: 0 12px 30px color-mix(in srgb, #000 18%, transparent); } .workspace-composer__overflow button { width: 100%; - min-height: 32px; - display: block; - overflow: hidden; - padding: 5px 8px; + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 10px; border: 0; - border-radius: 6px; + border-radius: 8px; background: transparent; color: var(--text-strong); font: inherit; - font-size: 12px; - line-height: 16px; + font-size: 13.5px; + font-weight: 450; + line-height: 18px; text-align: left; - text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } -.workspace-composer__agent-list { - display: flex; - flex-direction: column; -} - -.workspace-composer__agent-list button { - min-height: 42px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - white-space: normal; -} - -.workspace-composer__agent-list button > span:first-child { - min-width: 0; +.workspace-composer__capability-list { display: flex; - flex: 1; flex-direction: column; - gap: 1px; } -.workspace-composer__agent-list strong, -.workspace-composer__agent-list small { - display: block; +.workspace-composer__capability-list button > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.workspace-composer__agent-list strong { - color: var(--text-strong); - font-size: 12px; - font-weight: 500; - text-transform: capitalize; -} - -.workspace-composer__agent-list small { - color: var(--text-weak); - font-size: 10.5px; - font-weight: 400; - overflow: visible; - text-overflow: clip; - white-space: normal; -} - -.workspace-composer__agent-list button[aria-checked="true"] { - background: var(--surface-raised-base-hover); -} - -.workspace-composer__agent-check { +.workspace-composer__capability-value { flex: 0 0 auto; - color: var(--text-strong); - font-size: 11px; -} - -.workspace-composer__capability-heading { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - padding: 8px 10px 5px; - border-top: 1px solid var(--border-weak-base); -} - -.workspace-composer__capability-heading:first-child { - border-top: 0; -} - -.workspace-composer__capability-heading span { - color: var(--text-strong); - font-size: 11px; - font-weight: 600; + color: var(--text-weaker); + font-size: 12.5px; + font-weight: 400; } -.workspace-composer__capability-heading small { +.workspace-composer__capability-chevron { + display: inline-block; + margin-left: 5px; color: var(--text-weaker); - font-size: 10px; + font-size: 17px; + line-height: 1; + transform: translateY(1px); } -.workspace-composer__capability-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2px; - padding: 2px 5px 6px; +.workspace-composer__capability-switch { + position: relative; + width: 34px; + height: 20px; + flex: 0 0 34px; + border-radius: 999px; + background: var(--surface-raised-base-active); + box-shadow: inset 0 0 0 1px var(--border-base); + transition: background 140ms ease; } -.workspace-composer__capability-list button { - min-width: 0; - display: grid; - grid-template-columns: 18px minmax(0, 1fr); - align-items: start; - gap: 7px; - padding: 7px; - overflow: visible; - text-align: left; - text-overflow: clip; - white-space: normal; +.workspace-composer__capability-switch > span { + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--text-strong); + box-shadow: 0 1px 3px color-mix(in srgb, #000 26%, transparent); + transition: transform 140ms ease; } -.workspace-composer__capability-list button > span { - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; +.workspace-composer__capability-switch[data-checked="true"] { + background: #4f8cff; + box-shadow: none; } -.workspace-composer__capability-list strong { - color: var(--text-strong); - font-size: 11px; - font-weight: 600; +.workspace-composer__capability-switch[data-checked="true"] > span { + transform: translateX(14px); } -.workspace-composer__capability-list small { - color: var(--text-weak); - font-size: 10px; - line-height: 1.3; +.workspace-composer__capability-divider { + height: 1px; + margin: 7px 10px; + background: var(--border-weak-base); } @media (max-width: 560px) { @@ -338,10 +285,6 @@ left: -44px; max-height: min(440px, calc(100dvh - 140px)); } - - .workspace-composer__capability-list { - grid-template-columns: 1fr; - } } .workspace-composer__overflow button:hover:not(:disabled), diff --git a/frontend/workspace/src/components/prompt-input.tsx b/frontend/workspace/src/components/prompt-input.tsx index 3f21e9c8..8bdacce3 100644 --- a/frontend/workspace/src/components/prompt-input.tsx +++ b/frontend/workspace/src/components/prompt-input.tsx @@ -55,6 +55,7 @@ import { ModelSettingsPopover } from "./model-settings-popover" import { DialogSettings } from "./dialog-settings" import "./prompt-input.css" import { ATTACHMENT_ACCEPT, MAX_ATTACHMENT_BYTES, attachmentMime, attachmentSize } from "./prompt-attachment" +import { settingsApi } from "./settings/api" type PendingPrompt = { abort: AbortController @@ -71,6 +72,12 @@ interface PromptInputProps { onSubmit?: () => void } +type MemoryPreference = { + enabled: boolean + categories: Array + budget?: number +} + const EXAMPLES = [ "prompt.example.1", "prompt.example.2", @@ -130,6 +137,9 @@ export const PromptInput: Component = (props) => { let slashPopoverRef!: HTMLDivElement let modeRef: HTMLDetailsElement | undefined const [modeOpen, setModeOpen] = createSignal(false) + const [reviewAuto, setReviewAuto] = createSignal(false) + const [memory, setMemory] = createSignal({ enabled: true, categories: [] }) + const [capabilityBusy, setCapabilityBusy] = createSignal(false) const mirror = { input: false } @@ -170,18 +180,62 @@ export const PromptInput: Component = (props) => { queueMicrotask(() => fileInputRef.click()) } - const openSettings = (initial: "skills" | "memory" | "specialists") => { + const openSettings = (initial: "memory" | "specialists") => { setModeOpen(false) dialog.show(() => ) } - const openCapability = (name: "review" | "compute") => { + const openCompute = () => { setModeOpen(false) - document.dispatchEvent( - new CustomEvent(name === "review" ? "openscience:run-review" : "openscience:open-context", { - detail: name === "compute" ? { context: "kernels" } : undefined, - }), - ) + document.dispatchEvent(new CustomEvent("openscience:open-context", { detail: { context: "kernels" } })) + } + + const loadCapabilities = () => { + setCapabilityBusy(true) + void Promise.all([ + settingsApi<{ auto: boolean }>(sdk.url, platform.fetch ?? fetch, "/settings/review"), + settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/memory?scope=global"), + ]) + .then(([review, next]) => { + setReviewAuto(review.auto) + setMemory(next) + }) + .catch(() => undefined) + .finally(() => setCapabilityBusy(false)) + } + + const toggleReview = () => { + const previous = reviewAuto() + const next = !previous + setReviewAuto(next) + setCapabilityBusy(true) + void settingsApi<{ auto: boolean }>(sdk.url, platform.fetch ?? fetch, "/settings/review", { + method: "PUT", + body: JSON.stringify({ auto: next }), + }) + .then((state) => setReviewAuto(state.auto)) + .catch((error) => { + setReviewAuto(previous) + showToast({ variant: "error", title: "Could not update auto-review", description: String(error) }) + }) + .finally(() => setCapabilityBusy(false)) + } + + const toggleMemory = () => { + const previous = memory() + const next = { ...previous, enabled: !previous.enabled } + setMemory(next) + setCapabilityBusy(true) + void settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/memory?scope=global", { + method: "PUT", + body: JSON.stringify(next), + }) + .then(setMemory) + .catch((error) => { + setMemory(previous) + showToast({ variant: "error", title: "Could not update memory", description: String(error) }) + }) + .finally(() => setCapabilityBusy(false)) } onMount(() => { @@ -2044,7 +2098,11 @@ export const PromptInput: Component = (props) => { ref={modeRef} class="workspace-composer__overflow" open={modeOpen()} - onToggle={(event) => setModeOpen(event.currentTarget.open)} + onToggle={(event) => { + const open = event.currentTarget.open + setModeOpen(open) + if (open) loadCapabilities() + }} > = (props) => { title="Research capabilities" > - Capabilities -
-
- Research agent - General-purpose by default -
-
- - {(agent) => ( - - )} - -
-
- Tools - Open a working research surface -
- - + - -
diff --git a/frontend/workspace/src/components/settings/General.tsx b/frontend/workspace/src/components/settings/General.tsx index 6062b7c4..6cec7527 100644 --- a/frontend/workspace/src/components/settings/General.tsx +++ b/frontend/workspace/src/components/settings/General.tsx @@ -5,6 +5,7 @@ // • Appearance → the extracted AppearanceSections (theme, sounds, updates, …). import { Component, Show, createSignal, onMount, type JSX } from "solid-js" import { Button } from "@synsci/ui/button" +import { Switch } from "@synsci/ui/switch" import { showToast } from "@synsci/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" @@ -13,6 +14,7 @@ import { URLS } from "@/config/urls" import { FONT_CODE, FONT_SANS } from "@/styles/tokens" import { AppearanceSections } from "../settings-general" import { settingsApi } from "./api" +import { productPreferences } from "@/context/product-preferences" type Account = { session?: boolean @@ -24,6 +26,7 @@ type Account = { type Preferences = { intent: "commercial" | "non-commercial" extra_budget_usd: number + show_trace: boolean } export default function General() { @@ -49,7 +52,9 @@ export default function General() { } const loadPrefs = async () => { try { - setPrefs(await settingsApi(base(), fetchFn(), "/settings/preferences")) + const next = await settingsApi(base(), fetchFn(), "/settings/preferences") + setPrefs(next) + productPreferences.sync(next) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } @@ -65,6 +70,7 @@ export default function General() { body: JSON.stringify(patch), }) setPrefs(next) + productPreferences.sync(next) } const signOut = async () => { @@ -93,7 +99,7 @@ export default function General() {

General

-

Your account, licensing, and appearance.

+

Your account, workspace, licensing, and appearance.

@@ -174,6 +180,21 @@ export default function General() {
+
+
+ + void savePref({ show_trace })} + > + Show Trace + + +
+
+ {/* Appearance / theme / notifications / sounds / updates */}
diff --git a/frontend/workspace/src/context/product-preferences.ts b/frontend/workspace/src/context/product-preferences.ts new file mode 100644 index 00000000..7e867a73 --- /dev/null +++ b/frontend/workspace/src/context/product-preferences.ts @@ -0,0 +1,14 @@ +import { createSignal } from "solid-js" + +export type ProductPreferences = { + show_trace: boolean +} + +const [trace, setTrace] = createSignal(false) + +export const productPreferences = { + trace, + sync(preferences: Partial) { + setTrace(preferences.show_trace === true) + }, +} diff --git a/frontend/workspace/src/pages/session-sidebar-action.test.tsx b/frontend/workspace/src/pages/session-sidebar-action.test.tsx index bf69e16f..8515c646 100644 --- a/frontend/workspace/src/pages/session-sidebar-action.test.tsx +++ b/frontend/workspace/src/pages/session-sidebar-action.test.tsx @@ -67,6 +67,7 @@ describe("SessionSidebarActions", () => { expect(state.context()).toBe("kernels") expect(menu("Atlas")).toBeUndefined() expect(menu("Evidence")).toBeUndefined() + expect(menu("Trace")).toBeUndefined() const connected = mount(() => ( {}} /> @@ -79,6 +80,32 @@ describe("SessionSidebarActions", () => { expect(connected.textContent).toContain("Atlas") }) + test("keeps Trace hidden until the General preference is enabled", async () => { + const subject = await import("./session-sidebar-action") + const hidden = mount(() => ( + {}} + /> + )) + const visible = mount(() => ( + {}} + /> + )) + + expect(button(hidden, "Open session trace")).toBeNull() + expect(button(visible, "Open session trace")?.getAttribute("aria-pressed")).toBe("true") + }) + test("keeps rail labels semantic while visual density stays in the shell CSS", async () => { const subject = await import("./session-sidebar-action") const action = mount(() => ( diff --git a/frontend/workspace/src/pages/session-sidebar-action.tsx b/frontend/workspace/src/pages/session-sidebar-action.tsx index 50be4548..fa323a1a 100644 --- a/frontend/workspace/src/pages/session-sidebar-action.tsx +++ b/frontend/workspace/src/pages/session-sidebar-action.tsx @@ -7,6 +7,7 @@ export function CompactContextActions(props: { context: SessionContext contextOpen: boolean atlas: boolean + trace?: boolean onContext: (context: SessionContext) => void }): JSX.Element { return ( @@ -38,15 +39,17 @@ export function CompactContextActions(props: { Compute - + + + - - - - + + +
+ + + + + + + +
+ + + + +
+ + diff --git a/frontend/workspace/src/components/settings/Specialists.tsx b/frontend/workspace/src/components/settings/Specialists.tsx index c9fa356f..184c0606 100644 --- a/frontend/workspace/src/components/settings/Specialists.tsx +++ b/frontend/workspace/src/components/settings/Specialists.tsx @@ -41,6 +41,10 @@ const LABELS: Record = { reviewer: "Research reviewer", } type Mode = "primary" | "subagent" | "all" +type ReviewPreferences = { + auto: boolean + model: { providerID: string; modelID: string } | null +} export default function Specialists() { const sdk = useGlobalSDK() @@ -52,13 +56,18 @@ export default function Specialists() { // review.ts). Manual review stays always available from the session header; // this only opts into an automatic pass after a durable artifact save. const fetchFn = platform.fetch ?? fetch - const reviewApi = (init?: RequestInit) => settingsApi<{ auto: boolean }>(sdk.url, fetchFn, "/settings/review", init) + const reviewApi = (init?: RequestInit) => settingsApi(sdk.url, fetchFn, "/settings/review", init) const [reviewPrefs, reviewCtl] = createResource(() => reviewApi()) const [reviewSaving, setReviewSaving] = createSignal(false) async function toggleAutoReview(auto: boolean) { setReviewSaving(true) try { - reviewCtl.mutate(await reviewApi({ method: "PUT", body: JSON.stringify({ auto }) })) + reviewCtl.mutate( + await reviewApi({ + method: "PUT", + body: JSON.stringify({ auto, model: reviewPrefs()?.model ?? null }), + }), + ) } catch (err) { showToast({ variant: "error", title: "Could not update reviewer preference", description: message(err) }) } finally { diff --git a/frontend/workspace/src/context/models-pinned.test.ts b/frontend/workspace/src/context/models-pinned.test.ts index 9d7c1f30..dc9cf890 100644 --- a/frontend/workspace/src/context/models-pinned.test.ts +++ b/frontend/workspace/src/context/models-pinned.test.ts @@ -1,9 +1,17 @@ import { describe, expect, test } from "bun:test" -import { togglePinned } from "./models" +import { DEFAULT_PINNED_MODELS, togglePinned } from "./models" const model = (modelID: string, providerID = "anthropic") => ({ modelID, providerID }) describe("pinned models", () => { + test("starts with the requested flagship trio", () => { + expect(DEFAULT_PINNED_MODELS).toEqual([ + model("gpt-5.6-sol", "openai"), + model("claude-opus-5"), + model("kimi-k3", "moonshotai"), + ]) + }) + test("pins and unpins a model without duplicating it", () => { const pinned = togglePinned([], model("claude-opus-4-8")) expect(pinned).toEqual({ diff --git a/frontend/workspace/src/context/models.tsx b/frontend/workspace/src/context/models.tsx index d364d743..28b65a46 100644 --- a/frontend/workspace/src/context/models.tsx +++ b/frontend/workspace/src/context/models.tsx @@ -8,6 +8,12 @@ import { isChatModel, isFrontier, preferredModel, preferredModels, type ModelKey export { canonicalKey, FRONTIER_MODELS, type ModelKey } from "./model-catalog" +export const DEFAULT_PINNED_MODELS: ModelKey[] = [ + { providerID: "openai", modelID: "gpt-5.6-sol" }, + { providerID: "anthropic", modelID: "claude-opus-5" }, + { providerID: "moonshotai", modelID: "kimi-k3" }, +] + type Visibility = "show" | "hide" type User = ModelKey & { visibility: Visibility; favorite?: boolean } type Store = { @@ -42,7 +48,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( createStore({ user: [], recent: [], - pinned: undefined, + pinned: DEFAULT_PINNED_MODELS, variant: {}, tier: {}, }), @@ -136,11 +142,9 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( setStore("recent", uniq) } - // Existing users already have a useful recent-model history. Treat its - // first three entries as the initial pin set, then persist the first - // explicit pin/unpin action. The composer stays intentionally capped at - // three models while the full picker remains unrestricted. - const pinned = createMemo(() => (store.pinned ?? store.recent.slice(0, 3)).slice(0, 3)) + // The quick picker starts with one flagship from each of three major model + // families. Explicit user pinning remains authoritative after first load. + const pinned = createMemo(() => (store.pinned ?? DEFAULT_PINNED_MODELS).slice(0, 3)) const isPinned = (model: ModelKey) => pinned().some((item) => item.providerID === model.providerID && item.modelID === model.modelID) const togglePin = (model: ModelKey) => { diff --git a/tooling/sdk/js/src/v2/gen/sdk.gen.ts b/tooling/sdk/js/src/v2/gen/sdk.gen.ts index 7af200f2..2cba1a8f 100644 --- a/tooling/sdk/js/src/v2/gen/sdk.gen.ts +++ b/tooling/sdk/js/src/v2/gen/sdk.gen.ts @@ -1157,10 +1157,24 @@ export class Review extends HeyApiClient { public set( parameters?: { auto?: boolean + model?: { + providerID: string + modelID: string + } | null }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "auto" }] }]) + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "body", key: "auto" }, + { in: "body", key: "model" }, + ], + }, + ], + ) return (options?.client ?? this.client).put({ url: "/settings/review", ...options, @@ -1194,6 +1208,8 @@ export class Preferences extends HeyApiClient { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null }, options?: Options, ) { @@ -1206,6 +1222,8 @@ export class Preferences extends HeyApiClient { { in: "body", key: "intent" }, { in: "body", key: "extra_budget_usd" }, { in: "body", key: "show_trace" }, + { in: "body", key: "delegation_enabled" }, + { in: "body", key: "delegation_specialist" }, ], }, ], @@ -3047,6 +3065,7 @@ export class Session extends HeyApiClient { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string @@ -3066,6 +3085,7 @@ export class Session extends HeyApiClient { { in: "body", key: "agent" }, { in: "body", key: "noReply" }, { in: "body", key: "tools" }, + { in: "body", key: "delegation" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, { in: "body", key: "tier" }, @@ -3137,6 +3157,7 @@ export class Session extends HeyApiClient { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string @@ -3156,6 +3177,7 @@ export class Session extends HeyApiClient { { in: "body", key: "agent" }, { in: "body", key: "noReply" }, { in: "body", key: "tools" }, + { in: "body", key: "delegation" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, { in: "body", key: "tier" }, diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index 83afe8e9..0ff12600 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -191,6 +191,7 @@ export type UserMessage = { tools?: { [key: string]: boolean } + delegation?: boolean variant?: string tier?: string inference?: { @@ -5007,6 +5008,10 @@ export type SettingsReviewGetResponses = { */ 200: { auto: boolean + model?: { + providerID: string + modelID: string + } | null } } @@ -5015,6 +5020,10 @@ export type SettingsReviewGetResponse = SettingsReviewGetResponses[keyof Setting export type SettingsReviewSetData = { body?: { auto: boolean + model?: { + providerID: string + modelID: string + } | null } path?: never query?: never @@ -5027,6 +5036,10 @@ export type SettingsReviewSetResponses = { */ 200: { auto: boolean + model?: { + providerID: string + modelID: string + } | null } } @@ -5048,6 +5061,8 @@ export type SettingsPreferencesGetResponses = { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null } } @@ -5059,6 +5074,8 @@ export type SettingsPreferencesUpdateData = { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null } path?: never query?: never @@ -5074,6 +5091,8 @@ export type SettingsPreferencesUpdateResponses = { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null } } @@ -7006,6 +7025,7 @@ export type SessionPromptData = { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string @@ -7194,6 +7214,7 @@ export type SessionPromptAsyncData = { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string diff --git a/tooling/sdk/openapi.json b/tooling/sdk/openapi.json index f7836710..c8cc4df8 100644 --- a/tooling/sdk/openapi.json +++ b/tooling/sdk/openapi.json @@ -8180,6 +8180,29 @@ "properties": { "auto": { "type": "boolean" + }, + "model": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + { + "type": "null" + } + ] } }, "required": [ @@ -8210,6 +8233,29 @@ "properties": { "auto": { "type": "boolean" + }, + "model": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + { + "type": "null" + } + ] } }, "required": [ @@ -8228,6 +8274,29 @@ "properties": { "auto": { "type": "boolean" + }, + "model": { + "default": null, + "anyOf": [ + { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": [ + "providerID", + "modelID" + ] + }, + { + "type": "null" + } + ] } }, "required": [ @@ -8283,6 +8352,21 @@ "show_trace": { "default": false, "type": "boolean" + }, + "delegation_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_specialist": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -8334,6 +8418,21 @@ "show_trace": { "default": false, "type": "boolean" + }, + "delegation_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_specialist": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -8373,6 +8472,21 @@ "show_trace": { "default": false, "type": "boolean" + }, + "delegation_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_specialist": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -13454,6 +13568,9 @@ "type": "boolean" } }, + "delegation": { + "type": "boolean" + }, "system": { "type": "string" }, @@ -13841,6 +13958,9 @@ "type": "boolean" } }, + "delegation": { + "type": "boolean" + }, "system": { "type": "string" }, @@ -28915,6 +29035,9 @@ "type": "boolean" } }, + "delegation": { + "type": "boolean" + }, "variant": { "type": "string" }, From 085f0b41e6f2fc3846872e6dc56dc6e3097ed0ea Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 2 Aug 2026 20:23:50 +0800 Subject: [PATCH 6/7] polish workspace search and model controls --- .../src/server/routes/settings/preferences.ts | 3 + .../test/server/settings-preferences.test.ts | 4 + frontend/workspace/e2e/palette.spec.ts | 22 ++- frontend/workspace/e2e/settings.spec.ts | 10 ++ frontend/workspace/src/atlas/AppHeader.tsx | 7 +- .../src/atlas/CommandPalette.test.ts | 20 ++- .../workspace/src/atlas/CommandPalette.tsx | 106 +++++++++----- .../workspace/src/atlas/useGlobalKeys.test.ts | 12 ++ frontend/workspace/src/atlas/useGlobalKeys.ts | 2 +- .../src/components/dialog-select-model.css | 6 +- .../components/mobile-compose-model.test.ts | 6 +- .../src/components/model-settings-popover.css | 36 ++--- .../src/components/model-surface.test.ts | 2 +- .../src/components/settings/General.tsx | 14 ++ .../src/components/settings/ProviderKeys.tsx | 14 +- .../workspace/src/context/model-catalog.ts | 12 ++ .../src/context/models-catalog.test.ts | 14 ++ .../src/context/product-preferences.ts | 6 +- .../workspace/src/pages/session-shell.test.ts | 9 +- frontend/workspace/src/pages/session.tsx | 134 +++--------------- frontend/workspace/src/styles/atlas.css | 97 +++++++++++++ tooling/sdk/js/src/v2/gen/sdk.gen.ts | 2 + tooling/sdk/js/src/v2/gen/types.gen.ts | 3 + tooling/sdk/openapi.json | 12 ++ 24 files changed, 361 insertions(+), 192 deletions(-) create mode 100644 frontend/workspace/src/atlas/useGlobalKeys.test.ts diff --git a/backend/cli/src/server/routes/settings/preferences.ts b/backend/cli/src/server/routes/settings/preferences.ts index c8bb8f31..2cf90d56 100644 --- a/backend/cli/src/server/routes/settings/preferences.ts +++ b/backend/cli/src/server/routes/settings/preferences.ts @@ -26,6 +26,9 @@ export const Preferences = z.object({ // The session trace is an advanced observability surface. Keep the regular // workspace quiet unless the user explicitly enables it in General. show_trace: z.boolean().default(false), + // Atlas is part of the standard research workspace, but can be hidden from + // navigation without changing or deleting any Atlas data. + atlas_enabled: z.boolean().default(true), // Composer delegation is available by default. A selected specialist makes // the next normal prompt explicitly delegate to that subagent. delegation_enabled: z.boolean().default(true), diff --git a/backend/cli/test/server/settings-preferences.test.ts b/backend/cli/test/server/settings-preferences.test.ts index 5dee7600..94f10b1d 100644 --- a/backend/cli/test/server/settings-preferences.test.ts +++ b/backend/cli/test/server/settings-preferences.test.ts @@ -4,6 +4,7 @@ import { Preferences, SettingsPreferencesRoutes } from "../../src/server/routes/ test("trace navigation is opt-in by default", () => { expect(Preferences.parse({})).toMatchObject({ show_trace: false, + atlas_enabled: true, delegation_enabled: true, delegation_specialist: null, }) @@ -16,6 +17,7 @@ test("composer preferences persist through the settings route", async () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ show_trace: true, + atlas_enabled: false, delegation_enabled: false, delegation_specialist: "biology", }), @@ -23,6 +25,7 @@ test("composer preferences persist through the settings route", async () => { expect(update.status).toBe(200) expect((await update.json()) as Preferences).toMatchObject({ show_trace: true, + atlas_enabled: false, delegation_enabled: false, delegation_specialist: "biology", }) @@ -31,6 +34,7 @@ test("composer preferences persist through the settings route", async () => { expect(read.status).toBe(200) expect((await read.json()) as Preferences).toMatchObject({ show_trace: true, + atlas_enabled: false, delegation_enabled: false, delegation_specialist: "biology", }) diff --git a/frontend/workspace/e2e/palette.spec.ts b/frontend/workspace/e2e/palette.spec.ts index 52b11596..540ab1bc 100644 --- a/frontend/workspace/e2e/palette.spec.ts +++ b/frontend/workspace/e2e/palette.spec.ts @@ -1,15 +1,29 @@ import { test, expect } from "./fixtures" -test("search palette opens and closes", async ({ page, gotoSession }) => { +import { promptSelector } from "./utils" + +test("project search stays centered, local, and available from the composer", async ({ page, gotoSession }) => { await gotoSession() - await page.getByRole("button", { name: "Search and commands", exact: true }).click() + await page.getByRole("button", { name: "Search project", exact: true }).click() const dialog = page.getByRole("dialog", { name: "command palette" }) - const search = dialog.getByRole("textbox", { name: "search projects, sessions, messages, and artifacts" }) + const search = dialog.getByRole("textbox", { name: "Search this project" }) await expect(dialog).toBeVisible() await expect(search).toBeVisible() - await expect(dialog.getByRole("button", { name: /Settings/ })).toBeVisible() + await expect(dialog.getByRole("button", { name: /Open project files/ })).toBeVisible() + await expect(dialog.getByText("projects", { exact: true })).toHaveCount(0) + await expect(dialog.getByRole("button", { name: /Settings/ })).toHaveCount(0) + + const box = await dialog.boundingBox() + const viewport = page.viewportSize() + expect(box).toBeTruthy() + expect(viewport).toBeTruthy() + expect(Math.abs((box?.x ?? 0) + (box?.width ?? 0) / 2 - (viewport?.width ?? 0) / 2)).toBeLessThan(4) await page.keyboard.press("Escape") await expect(search).toHaveCount(0) + + await page.locator(promptSelector).click() + await page.keyboard.press("ControlOrMeta+K") + await expect(dialog).toBeVisible() }) diff --git a/frontend/workspace/e2e/settings.spec.ts b/frontend/workspace/e2e/settings.spec.ts index 6648e3f2..55874692 100644 --- a/frontend/workspace/e2e/settings.spec.ts +++ b/frontend/workspace/e2e/settings.spec.ts @@ -11,6 +11,16 @@ test("settings dialog navigates between sections and closes", async ({ page, got await dialog.getByRole("button", { name: "General", exact: true }).click() await expect(dialog.getByRole("heading", { name: "General" })).toBeVisible() + const atlas = dialog.getByRole("switch", { name: "Show Atlas", exact: true }) + const trace = dialog.getByRole("switch", { name: "Show Trace", exact: true }) + const atlasControl = atlas.locator("..").locator('[data-slot="switch-control"]') + await expect(atlas).toBeChecked() + await expect(trace).not.toBeChecked() + await atlasControl.click() + await expect(atlas).not.toBeChecked() + await atlasControl.click() + await expect(atlas).toBeChecked() + const back = dialog.getByRole("button", { name: "Back" }) const forward = dialog.getByRole("button", { name: "Forward" }) await expect(back).toBeEnabled() diff --git a/frontend/workspace/src/atlas/AppHeader.tsx b/frontend/workspace/src/atlas/AppHeader.tsx index 236c864f..74c86244 100644 --- a/frontend/workspace/src/atlas/AppHeader.tsx +++ b/frontend/workspace/src/atlas/AppHeader.tsx @@ -29,16 +29,18 @@ export function HeaderIconButton(props: { title: string children: JSX.Element class?: string + disabled?: boolean }): JSX.Element { return ( - - - -
- -
) } diff --git a/frontend/workspace/src/styles/atlas.css b/frontend/workspace/src/styles/atlas.css index f8e9d95f..9e48d631 100644 --- a/frontend/workspace/src/styles/atlas.css +++ b/frontend/workspace/src/styles/atlas.css @@ -6099,6 +6099,103 @@ button.research-launchpad__status-item:hover { } } +/* Compact project chrome -------------------------------------------------- */ + +@keyframes command-palette-in { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 8px)) scale(0.985); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.command-palette { + top: 50%; + left: 50%; + width: min(620px, calc(100vw - 32px)); + max-width: calc(100vw - 32px); + max-height: min(720px, 76vh); + transform: translate(-50%, -50%); + animation: command-palette-in 160ms var(--agent-ease); + border-radius: 14px; + background: color-mix(in srgb, var(--color-surface-solid) 96%, var(--color-bg)); + box-shadow: 0 24px 80px color-mix(in srgb, #000 30%, transparent); +} + +.session-sidebar__actions { + gap: 4px; + padding: 2px 5px 7px; +} + +.session-sidebar__group-label { + padding: 5px 7px 1px; + font-size: 8.5px; + letter-spacing: 0.06em; +} + +.session-sidebar__action, +.session-sidebar__new { + min-height: 36px; + grid-template-columns: 26px minmax(0, 1fr) auto; + gap: 6px; + padding: 4px 6px; + border-radius: 8px; +} + +.session-sidebar__action-icon { + width: 26px; + height: 26px; +} + +.session-sidebar__action-copy { + gap: 0; +} + +.session-sidebar__action-copy strong { + font-size: 12px; + line-height: 1.25; + font-weight: 500; +} + +.session-sidebar__action-copy > span { + font-size: 9.5px; + line-height: 1.2; +} + +.session-sidebar__action kbd { + min-width: 25px; + padding: 1px 4px; + font-size: 8.5px; + border-radius: 5px; +} + +.session-sidebar__label { + padding: 7px 8px 3px; + font-size: 10px; +} + +.session-scroller [data-component="user-message"] [data-slot="user-message-text"] { + max-width: min(72%, 560px); + padding: 7px 10px; + border: 0; + border-radius: 11px 11px 3px 11px; + background: color-mix(in srgb, var(--color-text) 7%, var(--color-bg)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-text) 8%, transparent); + font-size: 13px; + line-height: 1.5; +} + +@media (min-width: 720px) { + .session-sidebar { + width: 196px; + min-width: 196px; + margin-right: 8px; + } +} + .research-inspector__header { min-height: 40px; background: var(--color-bg); diff --git a/tooling/sdk/js/src/v2/gen/sdk.gen.ts b/tooling/sdk/js/src/v2/gen/sdk.gen.ts index 2cba1a8f..dc368a22 100644 --- a/tooling/sdk/js/src/v2/gen/sdk.gen.ts +++ b/tooling/sdk/js/src/v2/gen/sdk.gen.ts @@ -1208,6 +1208,7 @@ export class Preferences extends HeyApiClient { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + atlas_enabled?: boolean delegation_enabled?: boolean delegation_specialist?: string | null }, @@ -1222,6 +1223,7 @@ export class Preferences extends HeyApiClient { { in: "body", key: "intent" }, { in: "body", key: "extra_budget_usd" }, { in: "body", key: "show_trace" }, + { in: "body", key: "atlas_enabled" }, { in: "body", key: "delegation_enabled" }, { in: "body", key: "delegation_specialist" }, ], diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index 0ff12600..1db96698 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -5061,6 +5061,7 @@ export type SettingsPreferencesGetResponses = { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + atlas_enabled?: boolean delegation_enabled?: boolean delegation_specialist?: string | null } @@ -5074,6 +5075,7 @@ export type SettingsPreferencesUpdateData = { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + atlas_enabled?: boolean delegation_enabled?: boolean delegation_specialist?: string | null } @@ -5091,6 +5093,7 @@ export type SettingsPreferencesUpdateResponses = { intent?: "commercial" | "non-commercial" extra_budget_usd?: number show_trace?: boolean + atlas_enabled?: boolean delegation_enabled?: boolean delegation_specialist?: string | null } diff --git a/tooling/sdk/openapi.json b/tooling/sdk/openapi.json index c8cc4df8..e6302bcc 100644 --- a/tooling/sdk/openapi.json +++ b/tooling/sdk/openapi.json @@ -8353,6 +8353,10 @@ "default": false, "type": "boolean" }, + "atlas_enabled": { + "default": true, + "type": "boolean" + }, "delegation_enabled": { "default": true, "type": "boolean" @@ -8419,6 +8423,10 @@ "default": false, "type": "boolean" }, + "atlas_enabled": { + "default": true, + "type": "boolean" + }, "delegation_enabled": { "default": true, "type": "boolean" @@ -8473,6 +8481,10 @@ "default": false, "type": "boolean" }, + "atlas_enabled": { + "default": true, + "type": "boolean" + }, "delegation_enabled": { "default": true, "type": "boolean" From e40fedd2d8a474f8536e54a27361d650069167d4 Mon Sep 17 00:00:00 2001 From: Aayam Bansal Date: Sun, 2 Aug 2026 20:38:59 +0800 Subject: [PATCH 7/7] fix packaged trace and palette e2e --- frontend/workspace/e2e/context.spec.ts | 19 ++++++++++++++++++- frontend/workspace/e2e/palette.spec.ts | 4 +++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/workspace/e2e/context.spec.ts b/frontend/workspace/e2e/context.spec.ts index 1eb93c41..3641e9e7 100644 --- a/frontend/workspace/e2e/context.spec.ts +++ b/frontend/workspace/e2e/context.spec.ts @@ -1,5 +1,19 @@ import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { openSettings } from "./utils" + +async function setTrace(page: Parameters[0], enabled: boolean) { + const dialog = await openSettings(page) + await dialog.getByRole("button", { name: "General", exact: true }).click() + + const trace = dialog.getByRole("switch", { name: "Show Trace", exact: true }) + if ((await trace.isChecked()) !== enabled) { + await trace.locator("..").locator('[data-slot="switch-control"]').click() + } + + if (enabled) await expect(trace).toBeChecked() + if (!enabled) await expect(trace).not.toBeChecked() + await dialog.getByRole("button", { name: "Close" }).click() +} test("observable session context opens in the local trace", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke context ${Date.now()}` @@ -36,6 +50,8 @@ test("observable session context opens in the local trace", async ({ page, sdk, await gotoSession(sessionID) + await expect(page.getByRole("button", { name: "Open session trace", exact: true })).toHaveCount(0) + await setTrace(page, true) await page.getByRole("button", { name: "Open session trace", exact: true }).click() const trace = page.getByRole("region", { name: "Session trace", exact: true }) @@ -44,6 +60,7 @@ test("observable session context opens in the local trace", async ({ page, sdk, await expect(trace.getByText("first output", { exact: true })).toBeVisible() await expect(trace.getByText("Observable activity", { exact: true })).toBeVisible() } finally { + await setTrace(page, false).catch(() => undefined) await sdk.session.delete({ sessionID }).catch(() => undefined) } }) diff --git a/frontend/workspace/e2e/palette.spec.ts b/frontend/workspace/e2e/palette.spec.ts index 540ab1bc..61af58f4 100644 --- a/frontend/workspace/e2e/palette.spec.ts +++ b/frontend/workspace/e2e/palette.spec.ts @@ -18,7 +18,9 @@ test("project search stays centered, local, and available from the composer", as const viewport = page.viewportSize() expect(box).toBeTruthy() expect(viewport).toBeTruthy() - expect(Math.abs((box?.x ?? 0) + (box?.width ?? 0) / 2 - (viewport?.width ?? 0) / 2)).toBeLessThan(4) + // Account for scrollbar and subpixel differences in the packaged browser. + // The old right-anchored panel was hundreds of pixels off center. + expect(Math.abs((box?.x ?? 0) + (box?.width ?? 0) / 2 - (viewport?.width ?? 0) / 2)).toBeLessThan(12) await page.keyboard.press("Escape") await expect(search).toHaveCount(0)