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/preferences.ts b/backend/cli/src/server/routes/settings/preferences.ts index 281608dc..2cf90d56 100644 --- a/backend/cli/src/server/routes/settings/preferences.ts +++ b/backend/cli/src/server/routes/settings/preferences.ts @@ -23,6 +23,16 @@ 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), + // 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), + delegation_specialist: z.string().nullable().default(null), }) export type Preferences = z.infer 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/session/message-v2.ts b/backend/cli/src/session/message-v2.ts index 5c03e03e..2db369b5 100644 --- a/backend/cli/src/session/message-v2.ts +++ b/backend/cli/src/session/message-v2.ts @@ -332,6 +332,7 @@ export namespace MessageV2 { }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + delegation: z.boolean().optional(), variant: z.string().optional(), tier: z.string().optional(), inference: Inference.Info.optional(), diff --git a/backend/cli/src/session/prompt.ts b/backend/cli/src/session/prompt.ts index e913b44a..4f6cad39 100644 --- a/backend/cli/src/session/prompt.ts +++ b/backend/cli/src/session/prompt.ts @@ -137,6 +137,7 @@ export namespace SessionPrompt { .describe( "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", ), + delegation: z.boolean().optional(), system: z.string().optional(), variant: z.string().optional(), tier: z.string().optional(), @@ -805,6 +806,7 @@ export namespace SessionPrompt { session, model, tools: lastUser.tools, + delegation: lastUser.delegation, processor, bypassAgentCheck, messages: msgs, @@ -959,6 +961,7 @@ export namespace SessionPrompt { model: Provider.Model session: Session.Info tools?: Record + delegation?: boolean processor: SessionProcessor.Info bypassAgentCheck: boolean messages: MessageV2.WithParts[] @@ -1140,9 +1143,14 @@ export namespace SessionPrompt { tools[key] = item } + if (!allowsDelegation(input.delegation, input.bypassAgentCheck)) delete tools.task return tools } + export function allowsDelegation(enabled: boolean | undefined, explicit: boolean) { + return enabled !== false || explicit + } + async function createUserMessage(input: PromptInput) { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) const session = await Session.get(input.sessionID) @@ -1167,6 +1175,7 @@ export namespace SessionPrompt { created: Date.now(), }, tools: input.tools, + delegation: input.delegation, agent: agent.name, model, system: input.system, diff --git a/backend/cli/src/session/review.ts b/backend/cli/src/session/review.ts index 0f978663..b2a2f447 100644 --- a/backend/cli/src/session/review.ts +++ b/backend/cli/src/session/review.ts @@ -166,9 +166,11 @@ export namespace SessionReview { export async function start(sessionID: string, target?: Target): Promise { if (!target) await grant(sessionID) const review = await packet(sessionID, target) + const settings = await ReviewSettings.get().catch(() => undefined) void SessionPrompt.prompt({ sessionID, agent: review.agent, + model: settings?.model ?? undefined, parts: [{ type: "text", text: review.text }], }).catch((error) => log.error("review pass failed", { sessionID, error })) return "target" in review ? review.target : undefined diff --git a/backend/cli/src/settings/review.ts b/backend/cli/src/settings/review.ts index c7159d75..5f980493 100644 --- a/backend/cli/src/settings/review.ts +++ b/backend/cli/src/settings/review.ts @@ -7,15 +7,20 @@ import { Global } from "../global" // kicking off a reviewer pass automatically after a significant result (a // durable artifact save). Persisted like the other settings stores. export namespace ReviewSettings { + export const Model = z.object({ + providerID: z.string(), + modelID: z.string(), + }) export const State = z.object({ auto: z.boolean(), + model: Model.nullable().default(null), }) export type State = z.infer const file = path.join(Global.Path.data, "settings", "review.json") function fallback(): State { - return { auto: false } + return { auto: false, model: null } } export async function get(): Promise { 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/server/settings-preferences.test.ts b/backend/cli/test/server/settings-preferences.test.ts new file mode 100644 index 00000000..94f10b1d --- /dev/null +++ b/backend/cli/test/server/settings-preferences.test.ts @@ -0,0 +1,41 @@ +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({})).toMatchObject({ + show_trace: false, + atlas_enabled: true, + delegation_enabled: true, + delegation_specialist: null, + }) +}) + +test("composer preferences persist 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, + atlas_enabled: false, + delegation_enabled: false, + delegation_specialist: "biology", + }), + }) + expect(update.status).toBe(200) + expect((await update.json()) as Preferences).toMatchObject({ + show_trace: true, + atlas_enabled: false, + delegation_enabled: false, + delegation_specialist: "biology", + }) + + const read = await app.request("/") + 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/backend/cli/test/session/delegation.test.ts b/backend/cli/test/session/delegation.test.ts new file mode 100644 index 00000000..e3a5fdd6 --- /dev/null +++ b/backend/cli/test/session/delegation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test" +import { SessionPrompt } from "../../src/session/prompt" + +describe("composer delegation", () => { + test("keeps delegation available by default and when explicitly requested", () => { + expect(SessionPrompt.allowsDelegation(undefined, false)).toBe(true) + expect(SessionPrompt.allowsDelegation(true, false)).toBe(true) + expect(SessionPrompt.allowsDelegation(false, true)).toBe(true) + }) + + test("removes automatic delegation when the composer switch is off", () => { + expect(SessionPrompt.allowsDelegation(false, false)).toBe(false) + }) +}) 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/backend/cli/test/settings/review.test.ts b/backend/cli/test/settings/review.test.ts new file mode 100644 index 00000000..2a86493c --- /dev/null +++ b/backend/cli/test/settings/review.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, test } from "bun:test" +import { ReviewSettings } from "../../src/settings/review" + +afterEach(() => ReviewSettings.set({ auto: false, model: null })) + +test("reviewer settings default to the session model", async () => { + expect(ReviewSettings.State.parse({ auto: false })).toEqual({ auto: false, model: null }) +}) + +test("reviewer settings preserve an independent model selection", async () => { + const selected = { + auto: true, + model: { providerID: "anthropic", modelID: "claude-opus-5" }, + } + await ReviewSettings.set(selected) + expect(await ReviewSettings.get()).toEqual(selected) +}) 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 52b11596..61af58f4 100644 --- a/frontend/workspace/e2e/palette.spec.ts +++ b/frontend/workspace/e2e/palette.spec.ts @@ -1,15 +1,31 @@ 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() + // 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) + + await page.locator(promptSelector).click() + await page.keyboard.press("ControlOrMeta+K") + await expect(dialog).toBeVisible() }) diff --git a/frontend/workspace/e2e/session.spec.ts b/frontend/workspace/e2e/session.spec.ts index 071367ae..da03a7bb 100644 --- a/frontend/workspace/e2e/session.spec.ts +++ b/frontend/workspace/e2e/session.spec.ts @@ -26,6 +26,47 @@ test("can open an existing session and type into the prompt", async ({ page, sdk } }) +test("delegation and specialist controls persist without changing the model", async ({ page, openSession }) => { + await openSession() + + const model = page.locator("[data-model-settings-trigger]") + const selected = await model.getAttribute("aria-label") + const capabilities = page.getByRole("button", { name: "Research capabilities", exact: true }) + + await capabilities.click() + const delegation = page.getByRole("menuitemcheckbox", { name: "Delegation", exact: true }) + await expect(delegation).toHaveAttribute("aria-checked", "true") + await delegation.click() + await expect(delegation).toHaveAttribute("aria-checked", "false") + + await capabilities.click() + await capabilities.click() + await expect(delegation).toHaveAttribute("aria-checked", "false") + await delegation.click() + await expect(delegation).toHaveAttribute("aria-checked", "true") + + await page.getByRole("menuitem", { name: /^Specialist Research/ }).click() + await page.getByRole("menuitemradio", { name: /^Biology/ }).click() + await expect(page.getByRole("menuitem", { name: /^Specialist Biology/ })).toBeVisible() + + await capabilities.click() + await capabilities.click() + await expect(page.getByRole("menuitem", { name: /^Specialist Biology/ })).toBeVisible() + await page.getByRole("menuitem", { name: /^Specialist Biology/ }).click() + await page.getByRole("menuitemradio", { name: /^Research/ }).click() + + await page.getByRole("menuitem", { name: /^Reviewer model Same as session/ }).click() + const reviewer = page.getByRole("menuitemradio").nth(1) + const reviewerName = (await reviewer.getByRole("strong").textContent())?.trim() + if (!reviewerName) throw new Error("Reviewer model picker returned no model") + await reviewer.click() + await expect(page.getByRole("menuitem", { name: new RegExp(`^Reviewer model ${reviewerName}`) })).toBeVisible() + await page.getByRole("menuitem", { name: new RegExp(`^Reviewer model ${reviewerName}`) }).click() + await page.getByRole("menuitemradio", { name: /^Same as session/ }).click() + + await expect(model).toHaveAttribute("aria-label", selected ?? "") +}) + test("session lifecycle works through the sidebar UI", async ({ page, slug, sdk, gotoSession, openSession }) => { const renamedTitle = `e2e ui lifecycle ${Date.now()}` let sessionID: string | undefined @@ -69,7 +110,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 +137,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/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/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..62bb4ae3 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 @@ -70,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 = @@ -155,23 +158,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 +188,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 +212,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 +263,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(" 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)") }) @@ -52,7 +55,7 @@ test("mounts the unified compute surface for the kernels context", () => { 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/atlas/useGlobalKeys.test.ts b/frontend/workspace/src/atlas/useGlobalKeys.test.ts new file mode 100644 index 00000000..b1fe20d1 --- /dev/null +++ b/frontend/workspace/src/atlas/useGlobalKeys.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" + +const source = await Bun.file(new URL("./useGlobalKeys.ts", import.meta.url)).text() + +test("Cmd-K opens project search even while the composer is focused", () => { + const shortcut = source.indexOf('if (mod && key === "k")') + const typing = source.indexOf("if (isTypingTarget(event.target)) return", shortcut) + + expect(shortcut).toBeGreaterThan(0) + expect(typing).toBeGreaterThan(shortcut) + expect(source.slice(shortcut, typing)).toContain("uiStore.setPaletteOpen(true)") +}) diff --git a/frontend/workspace/src/atlas/useGlobalKeys.ts b/frontend/workspace/src/atlas/useGlobalKeys.ts index 8cdb2b12..114cf559 100644 --- a/frontend/workspace/src/atlas/useGlobalKeys.ts +++ b/frontend/workspace/src/atlas/useGlobalKeys.ts @@ -12,7 +12,6 @@ export function useGlobalKeys(input: { onNew?: () => void }) { const dialog = useDialog() const onKeyDown = (event: KeyboardEvent) => { if (dialog.active) return - if (isTypingTarget(event.target)) return const mod = event.metaKey || event.ctrlKey const key = event.key.toLowerCase() if (mod && key === "k") { @@ -20,6 +19,7 @@ export function useGlobalKeys(input: { onNew?: () => void }) { uiStore.setPaletteOpen(true) return } + if (isTypingTarget(event.target)) return if (event.key === "?") { event.preventDefault() uiStore.setHelpOpen(true) diff --git a/frontend/workspace/src/components/dialog-select-model.css b/frontend/workspace/src/components/dialog-select-model.css index 199aa77e..ecd201c3 100644 --- a/frontend/workspace/src/components/dialog-select-model.css +++ b/frontend/workspace/src/components/dialog-select-model.css @@ -130,8 +130,8 @@ @media (min-width: 720px) { [data-component="dialog"]:has(.model-picker-sheet) [data-slot="dialog-container"] { - width: min(calc(100vw - 32px), 540px); - height: min(calc(100dvh - 48px), 486px); + width: min(calc(100vw - 32px), 480px); + height: min(calc(100dvh - 48px), 440px); } [data-component="dialog"] [data-slot="dialog-content"].model-picker-sheet { @@ -195,7 +195,7 @@ } .model-picker-sheet .model-picker-sheet__list [data-slot="list-item"] { - min-height: 42px; + min-height: 38px; padding: 4px 8px !important; border-radius: 8px; } diff --git a/frontend/workspace/src/components/dialog-select-model.tsx b/frontend/workspace/src/components/dialog-select-model.tsx index 9707a9c8..7c7c97bc 100644 --- a/frontend/workspace/src/components/dialog-select-model.tsx +++ b/frontend/workspace/src/components/dialog-select-model.tsx @@ -24,6 +24,7 @@ import { DialogManageModels } from "./dialog-manage-models" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { displayProviderForModel } from "@/context/model-catalog" +import type { ModelKey } from "@/context/local" import "./dialog-select-model.css" type ModelGroup = "all" | "anthropic" | "openai" | "codex" | "other" @@ -54,6 +55,8 @@ const ModelList: Component<{ action?: JSX.Element group?: ModelGroup onPinLimit?: () => void + current?: ModelKey | null + onPick?: (model: ModelKey) => void }> = (props) => { const local = useLocal() const language = useLanguage() @@ -81,7 +84,13 @@ const ModelList: Component<{ emptyMessage={language.t("dialog.model.empty")} key={(x) => `${x.provider.id}:${x.id}`} items={models} - current={local.model.current()} + current={ + props.onPick + ? local.model + .list() + .find((model) => model.provider.id === props.current?.providerID && model.id === props.current?.modelID) + : local.model.current() + } filterKeys={["name"]} sortBy={(a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)} itemWrapper={(item, node) => ( @@ -102,9 +111,12 @@ const ModelList: Component<{ )} onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) + if (x && props.onPick) props.onPick({ modelID: x.id, providerID: x.provider.id }) + if (!props.onPick) { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + } props.onSelect() }} > @@ -312,7 +324,12 @@ export function ModelSelectorPopover(props: { ) } -export const DialogSelectModel: Component<{ provider?: string }> = (props) => { +export const DialogSelectModel: Component<{ + provider?: string + current?: ModelKey | null + onSelect?: (model: ModelKey) => void + title?: string +}> = (props) => { const dialog = useDialog() const language = useLanguage() const [filter, setFilter] = createSignal("all") @@ -320,7 +337,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { const manage = () => dialog.show(() => ) return ( - +
@@ -344,6 +361,8 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { dialog.close()} onPinLimit={() => setNotice("Three models are already pinned. Unpin one before adding another.")} class="model-picker-sheet__list" 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..efe00356 100644 --- a/frontend/workspace/src/components/mobile-compose-model.test.ts +++ b/frontend/workspace/src/components/mobile-compose-model.test.ts @@ -13,11 +13,37 @@ 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('class="workspace-composer__capability-switch"') + expect(prompt).toContain("onClick={toggleDelegation}") + expect(prompt).toContain('setCapabilityView("specialists")') + expect(prompt).toContain("selectSpecialist(option.name)") + expect(prompt).toContain("strong>Research") + expect(prompt).toContain('setCapabilityView("reviewer")') + expect(prompt).toContain("selectReviewerModel(") + expect(prompt).toContain('title="Reviewer model"') + expect(prompt).toContain("delegatedSpecialist(") + expect(prompt).toContain("delegation: capabilityDelegation") + expect(prompt).toContain("sdk.client.app.agents()") 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).not.toContain("local.agent.list()") + expect(prompt).not.toContain("local.agent.set(") + 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).not.toContain('openSettings("specialists")') + 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") @@ -34,6 +60,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(258px, calc(100vw - 24px))") + expect(css).toContain("left: -44px") + expect(css).toContain("max-height: min(440px, calc(100dvh - 140px))") + expect(css).toContain(".workspace-composer__capability-divider") + expect(css).not.toContain("grid-template-columns: repeat(2") expect(css).not.toContain("mobile-compose-sheet") }) @@ -80,9 +111,9 @@ describe("mobile compose and model sheets", () => { expect(picker).not.toContain("[&_[data-slot=list-item]]:!py-2") expect(picker).not.toContain("[&_[data-slot=list-search]]:!p-2") expect(css).toContain("@media (min-width: 720px)") - expect(css).toContain("width: min(calc(100vw - 32px), 540px)") - expect(css).toContain("height: min(calc(100dvh - 48px), 486px)") - expect(css).toContain("min-height: 42px") + expect(css).toContain("width: min(calc(100vw - 32px), 480px)") + expect(css).toContain("height: min(calc(100dvh - 48px), 440px)") + expect(css).toContain("min-height: 38px") expect(css).toContain("min-height: 34px") expect(css).toContain("font-size: 13px") expect(css).toContain("font-size: 11px") diff --git a/frontend/workspace/src/components/model-settings-popover.css b/frontend/workspace/src/components/model-settings-popover.css index 82d78de8..7300cd1f 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,17 +38,17 @@ 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; + min-height: 32px !important; max-width: min(210px, 40vw); gap: 6px !important; - padding: 0 7px !important; - border: 0 !important; - border-radius: 7px !important; - background: transparent !important; + padding: 0 9px !important; + border: 1px solid var(--model-control-border) !important; + border-radius: 8px !important; + background: var(--model-control-raised) !important; box-shadow: none !important; color: var(--model-control-text) !important; font-size: 13px !important; @@ -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,7 +97,7 @@ html[data-color-scheme="dark"] } [data-model-settings-popover] { - width: min(274px, calc(100vw - 24px)) !important; + width: min(286px, calc(100vw - 24px)) !important; padding: 6px !important; border: 1px solid var(--model-control-border-strong) !important; border-radius: 12px !important; @@ -102,10 +107,10 @@ html[data-color-scheme="dark"] } [data-model-settings-popover] .model-settings-row { - min-height: 34px; + min-height: 36px; gap: 10px; - padding: 0 9px; - border-radius: 7px; + padding: 0 10px; + border-radius: 8px; 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: 13px; 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: 50px; } .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,26 +162,27 @@ html[data-color-scheme="dark"] .model-settings-model strong { color: var(--model-control-text); - font-size: 12.5px; - font-weight: 500; + font-size: 13px; + font-weight: 520; letter-spacing: -0.005em; } .model-settings-model small { color: var(--model-control-faint); - font-size: 10.5px; + font-size: 11px; font-weight: 400; } .model-settings-more { - color: var(--model-control-muted) !important; + min-height: 40px !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; } @@ -185,8 +193,8 @@ html[data-color-scheme="dark"] } .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..689115f4 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(286px, 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-capabilities.test.ts b/frontend/workspace/src/components/prompt-capabilities.test.ts new file mode 100644 index 00000000..c6359801 --- /dev/null +++ b/frontend/workspace/src/components/prompt-capabilities.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { CORE_SPECIALISTS, delegatedSpecialist, isCoreSpecialist, specialistLabel } from "./prompt-capabilities" + +describe("prompt capabilities", () => { + test("forces the selected specialist only while delegation is enabled", () => { + expect(delegatedSpecialist(true, "biology", [])).toBe("biology") + expect(delegatedSpecialist(false, "biology", [])).toBeUndefined() + expect(delegatedSpecialist(true, null, [])).toBeUndefined() + }) + + test("keeps an explicit @specialist mention authoritative", () => { + expect(delegatedSpecialist(true, "biology", ["physics"])).toBeUndefined() + }) + + test("uses concise product labels", () => { + expect(specialistLabel("research")).toBe("Research") + expect(specialistLabel("ml")).toBe("ML") + expect(specialistLabel("custom-specialist")).toBe("custom specialist") + }) + + test("offers only the three delegated core specialists after Research", () => { + expect(CORE_SPECIALISTS).toEqual(["biology", "physics", "ml"]) + expect(isCoreSpecialist("biology")).toBe(true) + expect(isCoreSpecialist("reviewer")).toBe(false) + expect(isCoreSpecialist("docs")).toBe(false) + }) +}) diff --git a/frontend/workspace/src/components/prompt-capabilities.ts b/frontend/workspace/src/components/prompt-capabilities.ts new file mode 100644 index 00000000..dec1a9db --- /dev/null +++ b/frontend/workspace/src/components/prompt-capabilities.ts @@ -0,0 +1,44 @@ +export type CapabilityPreferences = { + delegation_enabled: boolean + delegation_specialist: string | null +} + +export type ReviewPreferences = { + auto: boolean + model: { providerID: string; modelID: string } | null +} + +export type SpecialistOption = { + name: string + description?: string +} + +const LABELS: Record = { + research: "Research", + biology: "Biology", + physics: "Physics", + ml: "ML", + write: "Scientific writing", + docs: "Documentation", + task: "General", + explore: "Exploration", + "literature-review": "Literature review", + critique: "Scientific critique", + "physics-critique": "Physics critique", + reviewer: "Research reviewer", +} + +export const CORE_SPECIALISTS = ["biology", "physics", "ml"] as const + +export function isCoreSpecialist(name: string) { + return CORE_SPECIALISTS.some((specialist) => specialist === name) +} + +export function specialistLabel(name: string) { + return LABELS[name] ?? name.replaceAll("-", " ") +} + +export function delegatedSpecialist(enabled: boolean, selected: string | null, explicit: string[]) { + if (!enabled || !selected || explicit.length > 0) return undefined + return selected +} diff --git a/frontend/workspace/src/components/prompt-input.css b/frontend/workspace/src/components/prompt-input.css index aea3bc08..dd0f0e3d 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,91 +173,190 @@ 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(280px, 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 { +.workspace-composer__capability-list { display: flex; flex-direction: column; } -.workspace-composer__agent-list button { - min-height: 42px; +.workspace-composer__capability-list button > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-composer__capability-value { + min-width: 0; + max-width: 142px; + display: inline-flex; + align-items: center; + overflow: hidden; + flex: 0 0 auto; + color: var(--text-weaker); + font-size: 12.5px; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-composer__capability-chevron { + display: inline-block; + margin-left: 5px; + color: var(--text-weaker); + font-size: 17px; + line-height: 1; + transform: translateY(1px); +} + +.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-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-switch[data-checked="true"] { + background: #4f8cff; + box-shadow: none; +} + +.workspace-composer__capability-switch[data-checked="true"] > span { + transform: translateX(14px); +} + +.workspace-composer__capability-divider { + height: 1px; + margin: 7px 10px; + background: var(--border-weak-base); +} + +.workspace-composer__specialist-list { display: flex; + flex-direction: column; +} + +.workspace-composer__specialist-list .workspace-composer__specialist-back { + min-height: 38px; + justify-content: flex-start; + gap: 9px; + color: var(--text-strong); +} + +.workspace-composer__specialist-back > span { + color: var(--text-weaker); + font-size: 19px; + line-height: 1; +} + +.workspace-composer__specialist-back strong { + font-size: 13.5px; + font-weight: 550; +} + +.workspace-composer__specialist-list .workspace-composer__specialist-option { + min-height: 54px; align-items: center; - justify-content: space-between; - gap: 10px; + padding-block: 7px; white-space: normal; } -.workspace-composer__agent-list button > span:first-child { +.workspace-composer__specialist-option > span:first-child { min-width: 0; display: flex; flex: 1; flex-direction: column; - gap: 1px; + gap: 2px; } -.workspace-composer__agent-list strong, -.workspace-composer__agent-list small { +.workspace-composer__specialist-option strong, +.workspace-composer__specialist-option small { display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } -.workspace-composer__agent-list strong { +.workspace-composer__specialist-option strong { color: var(--text-strong); - font-size: 12px; - font-weight: 500; - text-transform: capitalize; + font-size: 13px; + font-weight: 520; } -.workspace-composer__agent-list small { - color: var(--text-weak); +.workspace-composer__specialist-option small { + display: -webkit-box; + overflow: hidden; + color: var(--text-weaker); font-size: 10.5px; font-weight: 400; + line-height: 1.3; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } -.workspace-composer__agent-list button[aria-checked="true"] { - background: var(--surface-raised-base-hover); +.workspace-composer__specialist-option [data-slot="icon-svg"] { + flex: 0 0 auto; + color: #4f8cff; } -.workspace-composer__agent-check { - flex: 0 0 auto; - color: var(--text-strong); - font-size: 11px; +@media (max-width: 560px) { + .workspace-composer__overflow > div { + left: -44px; + max-height: min(440px, calc(100dvh - 140px)); + } } .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 a69c4e90..6195ec8c 100644 --- a/frontend/workspace/src/components/prompt-input.tsx +++ b/frontend/workspace/src/components/prompt-input.tsx @@ -14,7 +14,7 @@ import { } from "solid-js" import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" -import { useLocal } from "@/context/local" +import { useLocal, type ModelKey } from "@/context/local" import { useFile, type FileSelection } from "@/context/file" import { ContentPart, @@ -46,15 +46,26 @@ import { Worktree as WorktreeState } from "@/utils/worktree" import { useLanguage } from "@/context/language" import { useGlobalSync } from "@/context/global-sync" import { usePlatform } from "@/context/platform" -import { createOpenScienceClient, type Message, type Part } from "@synsci/sdk/v2/client" +import { createOpenScienceClient, type Agent, type Message, type Part } from "@synsci/sdk/v2/client" import { Binary } from "@synsci/util/binary" import { showToast } from "@synsci/ui/toast" import { uiStore } from "@/atlas/store/ui" import { projectHref, projectPathname } from "@/utils/project-route" -import { ModelSettingsPopover } from "./model-settings-popover" +import { displayProviderForModel } from "@/context/model-catalog" +import { DialogSelectModel } from "./dialog-select-model" +import { ModelSettingsPopover, modelSummary } 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" +import { + delegatedSpecialist, + isCoreSpecialist, + specialistLabel, + type CapabilityPreferences, + type ReviewPreferences, + type SpecialistOption, +} from "./prompt-capabilities" type PendingPrompt = { abort: AbortController @@ -71,6 +82,12 @@ interface PromptInputProps { onSubmit?: () => void } +type MemoryPreference = { + enabled: boolean + categories: Array + budget?: number +} + const EXAMPLES = [ "prompt.example.1", "prompt.example.2", @@ -130,6 +147,43 @@ export const PromptInput: Component = (props) => { let slashPopoverRef!: HTMLDivElement let modeRef: HTMLDetailsElement | undefined const [modeOpen, setModeOpen] = createSignal(false) + const [reviewAuto, setReviewAuto] = createSignal(false) + const [reviewModel, setReviewModel] = createSignal(null) + const [memory, setMemory] = createSignal({ enabled: true, categories: [] }) + const [delegation, setDelegation] = createSignal(true) + const [specialist, setSpecialist] = createSignal(null) + const [specialists, setSpecialists] = createSignal([]) + const [capabilityView, setCapabilityView] = createSignal<"main" | "specialists" | "reviewer">("main") + const [capabilityBusy, setCapabilityBusy] = createSignal(false) + + const reviewModels = createMemo(() => { + const options = [ + ...local.model.pinned(), + local.model.current(), + ...local.model.recent(), + ...local.model + .list() + .filter((model) => local.model.visible({ providerID: model.provider.id, modelID: model.id })), + ].filter((model): model is NonNullable => Boolean(model)) + const seen = new Set() + return options + .filter((model) => { + const key = `${model.provider.id}/${model.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .slice(0, 3) + }) + + const reviewerLabel = createMemo(() => { + const selected = reviewModel() + if (!selected) return "Same as session" + return ( + local.model.list().find((model) => model.provider.id === selected.providerID && model.id === selected.modelID) + ?.name ?? selected.modelID + ) + }) const mirror = { input: false } @@ -170,6 +224,145 @@ export const PromptInput: Component = (props) => { queueMicrotask(() => fileInputRef.click()) } + const openCompute = () => { + setModeOpen(false) + document.dispatchEvent(new CustomEvent("openscience:open-context", { detail: { context: "kernels" } })) + } + + const loadCapabilities = () => { + setCapabilityBusy(true) + void Promise.all([ + settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/review"), + settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/memory?scope=global"), + settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/preferences"), + sdk.client.app.agents(), + ]) + .then(([review, next, preferences, response]) => { + setReviewAuto(review.auto) + setReviewModel(review.model) + setMemory(next) + setDelegation(preferences.delegation_enabled) + setSpecialist(preferences.delegation_specialist) + setSpecialists( + ((response.data ?? []) as Agent[]) + .filter((agent) => agent.mode === "subagent" && isCoreSpecialist(agent.name)) + .map((agent) => ({ name: agent.name, description: agent.description })), + ) + }) + .catch(() => undefined) + .finally(() => setCapabilityBusy(false)) + } + + const saveDelegation = (patch: Partial) => + settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/preferences", { + method: "PATCH", + body: JSON.stringify(patch), + }) + + const toggleDelegation = () => { + const previous = delegation() + const next = !previous + setDelegation(next) + setCapabilityBusy(true) + void saveDelegation({ delegation_enabled: next }) + .then((preferences) => { + setDelegation(preferences.delegation_enabled) + setSpecialist(preferences.delegation_specialist) + }) + .catch((error) => { + setDelegation(previous) + showToast({ variant: "error", title: "Could not update delegation", description: String(error) }) + }) + .finally(() => setCapabilityBusy(false)) + } + + const selectSpecialist = (name: string | null) => { + const previous = specialist() + setSpecialist(name) + setCapabilityBusy(true) + void saveDelegation({ delegation_specialist: name }) + .then((preferences) => { + setDelegation(preferences.delegation_enabled) + setSpecialist(preferences.delegation_specialist) + setCapabilityView("main") + }) + .catch((error) => { + setSpecialist(previous) + showToast({ variant: "error", title: "Could not select specialist", description: String(error) }) + }) + .finally(() => setCapabilityBusy(false)) + } + + const toggleReview = () => { + const previous = reviewAuto() + const next = !previous + setReviewAuto(next) + setCapabilityBusy(true) + void settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/review", { + method: "PUT", + body: JSON.stringify({ auto: next, model: reviewModel() }), + }) + .then((state) => { + setReviewAuto(state.auto) + setReviewModel(state.model) + }) + .catch((error) => { + setReviewAuto(previous) + showToast({ variant: "error", title: "Could not update auto-review", description: String(error) }) + }) + .finally(() => setCapabilityBusy(false)) + } + + const selectReviewerModel = (model: ModelKey | null, returnToMenu = true) => { + const previous = reviewModel() + setReviewModel(model) + setCapabilityBusy(true) + void settingsApi(sdk.url, platform.fetch ?? fetch, "/settings/review", { + method: "PUT", + body: JSON.stringify({ auto: reviewAuto(), model }), + }) + .then((state) => { + setReviewAuto(state.auto) + setReviewModel(state.model) + if (returnToMenu) setCapabilityView("main") + }) + .catch((error) => { + setReviewModel(previous) + showToast({ variant: "error", title: "Could not select reviewer model", description: String(error) }) + }) + .finally(() => setCapabilityBusy(false)) + } + + const openReviewerModels = () => { + setModeOpen(false) + queueMicrotask(() => + dialog.show(() => ( + selectReviewerModel(model, 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(() => { const dismiss = (event: PointerEvent) => { if (!modeOpen()) return @@ -1214,6 +1407,8 @@ export const PromptInput: Component = (props) => { const agent = currentAgent.name const variant = local.model.variant.prompt() const tier = local.model.tier.prompt() + const capabilityDelegation = delegation() + const capabilitySpecialist = specialist() const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { @@ -1411,6 +1606,21 @@ export const PromptInput: Component = (props) => { }, })) + const selectedSpecialist = delegatedSpecialist( + capabilityDelegation, + capabilitySpecialist, + agentAttachments.map((attachment) => attachment.name), + ) + const specialistParts = selectedSpecialist + ? [ + { + id: Identifier.ascending("part"), + type: "agent" as const, + name: selectedSpecialist, + }, + ] + : [] + const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) const context = prompt.context.items().slice() @@ -1496,6 +1706,7 @@ export const PromptInput: Component = (props) => { textPart, ...fileAttachmentParts, ...contextParts, + ...specialistParts, ...agentAttachmentParts, ...imageAttachmentParts, ] @@ -1658,6 +1869,7 @@ export const PromptInput: Component = (props) => { model, messageID, parts: requestParts, + delegation: capabilityDelegation, variant, tier, }) @@ -2030,53 +2242,226 @@ 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) { + setCapabilityView("main") + loadCapabilities() + } + }} > - {local.agent.current()?.name ?? "Agent"} - -
-
- - {(agent) => ( +
+ + +
+ + + + + + + +
+ + + + {(option) => ( + + )} + +
+
+ +
+ + diff --git a/frontend/workspace/src/components/settings/General.tsx b/frontend/workspace/src/components/settings/General.tsx index 6062b7c4..ca1158b0 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,8 @@ type Account = { type Preferences = { intent: "commercial" | "non-commercial" extra_budget_usd: number + show_trace: boolean + atlas_enabled: boolean } export default function General() { @@ -49,7 +53,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 +71,7 @@ export default function General() { body: JSON.stringify(patch), }) setPrefs(next) + productPreferences.sync(next) } const signOut = async () => { @@ -93,7 +100,7 @@ export default function General() {

General

-

Your account, licensing, and appearance.

+

Your account, workspace, licensing, and appearance.

@@ -174,6 +181,34 @@ export default function General() {
+
+
+ + void savePref({ atlas_enabled })} + > + Show Atlas + + + + void savePref({ show_trace })} + > + Show Trace + + +
+
+ {/* Appearance / theme / notifications / sounds / updates */}
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/ProviderKeys.tsx b/frontend/workspace/src/components/settings/ProviderKeys.tsx index 9d060090..ffd21415 100644 --- a/frontend/workspace/src/components/settings/ProviderKeys.tsx +++ b/frontend/workspace/src/components/settings/ProviderKeys.tsx @@ -3,7 +3,9 @@ import { Button } from "@synsci/ui/button" import type { Provider } from "@synsci/sdk/v2/client" import { StatusDot } from "@/atlas/shared/StatusDot" import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" import { useProviders } from "@/hooks/use-providers" +import { isUserProviderConnection } from "@/context/model-catalog" import { MODEL_PROVIDERS, MODEL_PROVIDER_LABELS, modelProvider } from "./model-providers" const SOURCES: Record = { @@ -31,12 +33,22 @@ const SOURCES: Record void }) { const sdk = useGlobalSDK() + const sync = useGlobalSync() const providers = useProviders() const [provider, setProvider] = createSignal(MODEL_PROVIDERS[0].id) const [key, setKey] = createSignal("") const [saving, setSaving] = createSignal(false) const connected = createMemo(() => - providers.connected().filter((item) => MODEL_PROVIDERS.some((provider) => provider.id === item.id)), + providers + .connected() + .filter((item) => MODEL_PROVIDERS.some((provider) => provider.id === item.id)) + .filter((item) => + isUserProviderConnection({ + providerID: item.id, + source: item.source, + billing: sync.data.config.billing?.llm, + }), + ), ) const source = (item: { id: string }) => SOURCES[(item as { source?: Provider["source"] }).source ?? "api"] 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/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/model-catalog.ts b/frontend/workspace/src/context/model-catalog.ts index bd1fb566..5b664847 100644 --- a/frontend/workspace/src/context/model-catalog.ts +++ b/frontend/workspace/src/context/model-catalog.ts @@ -120,12 +120,24 @@ type CatalogModel = { } export function isChatModel(model: CatalogModel): boolean { + if (/(^|[/._-])(?:text-)?embeddings?([/._-]|$)/i.test(model.id)) return false + if (/(^|[/._-])embed([/._-]|$)/i.test(model.id)) return false const output = model.capabilities?.output if (!output) return true if (output.text === false) return false return !output.audio && !output.image && !output.video } +export function isUserProviderConnection(input: { + providerID: string + source?: "env" | "config" | "custom" | "api" + billing?: "managed" | "byok" | null +}): boolean { + if (input.providerID !== "openrouter") return true + if (input.source === "api") return true + return input.billing === "byok" +} + export function foldedRouteMode(model: ModelKey, target: CatalogModel): string | undefined { if (model.providerID !== "openrouter" || target.provider.id !== model.providerID) return undefined const match = model.modelID.match(/-(fast)$/) diff --git a/frontend/workspace/src/context/models-catalog.test.ts b/frontend/workspace/src/context/models-catalog.test.ts index ca009d83..aa561af6 100644 --- a/frontend/workspace/src/context/models-catalog.test.ts +++ b/frontend/workspace/src/context/models-catalog.test.ts @@ -6,6 +6,7 @@ import { FRONTIER_MODELS, isChatModel, isFrontier, + isUserProviderConnection, preferredModel, preferredModels, routableModelKey, @@ -110,6 +111,19 @@ describe("frontier model canonicalization", () => { capabilities: { output: { text: true, image: false } }, }), ).toBe(true) + + for (const id of ["text-embedding-3-large", "text-embedding-3-small", "text-embedding-ada-002"]) { + expect(isChatModel({ id, provider: { id: "openai" } })).toBe(false) + } + expect(isChatModel({ id: "nomic-embed-text", provider: { id: "openrouter" } })).toBe(false) + }) + + test("managed OpenRouter credentials are not presented as user provider setup", () => { + expect(isUserProviderConnection({ providerID: "openrouter", source: "config", billing: "managed" })).toBe(false) + expect(isUserProviderConnection({ providerID: "openrouter", source: "env", billing: null })).toBe(false) + expect(isUserProviderConnection({ providerID: "openrouter", source: "api", billing: "managed" })).toBe(true) + expect(isUserProviderConnection({ providerID: "openrouter", source: "config", billing: "byok" })).toBe(true) + expect(isUserProviderConnection({ providerID: "anthropic", source: "env", billing: "managed" })).toBe(true) }) test("stable Anthropic aliases win over dated duplicates", () => { 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/frontend/workspace/src/context/product-preferences.ts b/frontend/workspace/src/context/product-preferences.ts new file mode 100644 index 00000000..4b368d49 --- /dev/null +++ b/frontend/workspace/src/context/product-preferences.ts @@ -0,0 +1,18 @@ +import { createSignal } from "solid-js" + +export type ProductPreferences = { + show_trace: boolean + atlas_enabled: boolean +} + +const [trace, setTrace] = createSignal(false) +const [atlas, setAtlas] = createSignal(true) + +export const productPreferences = { + trace, + atlas, + sync(preferences: Partial) { + if (preferences.show_trace !== undefined) setTrace(preferences.show_trace === true) + if (preferences.atlas_enabled !== undefined) setAtlas(preferences.atlas_enabled !== false) + }, +} 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..3d67186b 100644 --- a/frontend/workspace/src/pages/session-shell.test.ts +++ b/frontend/workspace/src/pages/session-shell.test.ts @@ -50,7 +50,8 @@ describe("focused workspace shell", () => { expect(session).not.toContain('role="tabpanel"') expect(session).not.toContain("centerPanelId") expect(session).not.toContain("centerTabId") - expect(session).toContain('class="workspace-header__menu"') + expect(session).not.toContain('class="workspace-header__menu"') + expect(session).not.toContain('class="workspace-header__search"') expect(session).not.toContain(" { expect(session).not.toContain('label="Skills"') }) - test("keeps contextual navigation in the rail and compact More menu", () => { + test("keeps contextual navigation in the compact left rail", () => { const session = read("./session.tsx") const action = read("./session-sidebar-action.tsx") @@ -105,7 +106,7 @@ describe("focused workspace shell", () => { expect(session).toContain("dialog.show(() => )") expect(action).toContain('onClick={(_event?: Event) => props.onContext("files")}') expect(action).toContain('onClick={(_event?: Event) => props.onContext("terminal")}') - expect(session).toContain(" { expect(session).toContain('class="session-sidebar__session"') expect(session).toContain('class="session-sidebar__session-title"') expect(session).not.toContain("DateTime.fromMillis") - expect(styles).toContain("width: 210px") + expect(styles).toContain("width: 196px") expect(styles).toContain("min-height: 31px") 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('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"]') + }) + test("keeps research tools in a route-owned contextual surface", () => { const pane = read("../atlas/RightPane.tsx") @@ -200,9 +214,10 @@ describe("focused workspace shell", () => { expect(pane).toContain(" { 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 - + + +
) @@ -1096,21 +1176,11 @@ function Header(props: { projectName: string directory: string trust: ProjectTrustApi - isDark: boolean onBack: () => void - onOpenPalette: () => void - onOpenHelp: () => void - onOpenSettings: () => void onRunReview: () => void reviewDisabled: boolean - onToggleTheme: () => void onToggleSessions: () => void - onContext: (context: SessionContext) => void - context: SessionContext - contextOpen: boolean - atlas: boolean }): JSX.Element { - const [menu, setMenu] = createSignal(false) return ( @@ -1136,84 +1206,14 @@ function Header(props: { api={props.trust} /> - - + + -
setMenu(false)}> - setMenu((open) => !open)} - title="Workspace controls" - > - - - - - -
) } @@ -1294,9 +1294,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 @@ -1304,14 +1306,17 @@ function SessionsSidebar(props: { contextOpen: boolean artifact: boolean atlas: boolean + trace: boolean 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..9e48d631 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; @@ -5968,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/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..dc368a22 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", @@ -755,7 +770,7 @@ export class Storage extends HeyApiClient { /** * Reset data location * - * Remove the data-location pointer so the default location is used on next launch. + * Remove the data-location pointer so ~/.openscience is used on next launch. */ public resetLocation(options?: Options) { return (options?.client ?? this.client).delete({ @@ -1142,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, @@ -1178,6 +1207,10 @@ export class Preferences extends HeyApiClient { reasoning_effort?: "minimal" | "low" | "medium" | "high" intent?: "commercial" | "non-commercial" extra_budget_usd?: number + show_trace?: boolean + atlas_enabled?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null }, options?: Options, ) { @@ -1189,6 +1222,10 @@ export class Preferences extends HeyApiClient { { in: "body", key: "reasoning_effort" }, { 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" }, ], }, ], @@ -1263,6 +1300,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 +1585,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 +2642,7 @@ export class Session extends HeyApiClient { title?: string time?: { archived?: number + pinned?: number } }, options?: Options, @@ -3012,6 +3067,7 @@ export class Session extends HeyApiClient { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string @@ -3031,6 +3087,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" }, @@ -3102,6 +3159,7 @@ export class Session extends HeyApiClient { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string @@ -3121,6 +3179,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 0561b2e7..1db96698 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?: { @@ -826,6 +827,7 @@ export type Session = { updated: number compacting?: number archived?: number + pinned?: number } permission?: PermissionRuleset revert?: { @@ -2416,6 +2418,10 @@ export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthRespo export type GlobalProjectCreateData = { body?: { name: string + sources?: Array<{ + path: string + access?: "read" | "write" + }> } path?: never query?: never @@ -5002,6 +5008,10 @@ export type SettingsReviewGetResponses = { */ 200: { auto: boolean + model?: { + providerID: string + modelID: string + } | null } } @@ -5010,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 @@ -5022,6 +5036,10 @@ export type SettingsReviewSetResponses = { */ 200: { auto: boolean + model?: { + providerID: string + modelID: string + } | null } } @@ -5042,6 +5060,10 @@ export type SettingsPreferencesGetResponses = { reasoning_effort?: "minimal" | "low" | "medium" | "high" intent?: "commercial" | "non-commercial" extra_budget_usd?: number + show_trace?: boolean + atlas_enabled?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null } } @@ -5052,6 +5074,10 @@ export type SettingsPreferencesUpdateData = { reasoning_effort?: "minimal" | "low" | "medium" | "high" intent?: "commercial" | "non-commercial" extra_budget_usd?: number + show_trace?: boolean + atlas_enabled?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null } path?: never query?: never @@ -5066,6 +5092,10 @@ export type SettingsPreferencesUpdateResponses = { reasoning_effort?: "minimal" | "low" | "medium" | "high" intent?: "commercial" | "non-commercial" extra_budget_usd?: number + show_trace?: boolean + atlas_enabled?: boolean + delegation_enabled?: boolean + delegation_specialist?: string | null } } @@ -5226,6 +5256,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 +6197,7 @@ export type SessionUpdateData = { title?: string time?: { archived?: number + pinned?: number } } path: { @@ -6974,6 +7028,7 @@ export type SessionPromptData = { tools?: { [key: string]: boolean } + delegation?: boolean system?: string variant?: string tier?: string @@ -7162,6 +7217,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 e166e38c..e6302bcc 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": [ @@ -1378,7 +1403,7 @@ "delete": { "operationId": "settings.storage.resetLocation", "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.", "responses": { "200": { "description": "Reset", @@ -8155,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": [ @@ -8185,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": [ @@ -8203,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": [ @@ -8254,6 +8348,29 @@ "default": 0, "type": "number", "minimum": 0 + }, + "show_trace": { + "default": false, + "type": "boolean" + }, + "atlas_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_specialist": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -8301,6 +8418,29 @@ "default": 0, "type": "number", "minimum": 0 + }, + "show_trace": { + "default": false, + "type": "boolean" + }, + "atlas_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_specialist": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -8336,6 +8476,29 @@ "default": 0, "type": "number", "minimum": 0 + }, + "show_trace": { + "default": false, + "type": "boolean" + }, + "atlas_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_enabled": { + "default": true, + "type": "boolean" + }, + "delegation_specialist": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -8774,6 +8937,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 +11044,9 @@ "properties": { "archived": { "type": "number" + }, + "pinned": { + "type": "number" } } } @@ -13361,6 +13580,9 @@ "type": "boolean" } }, + "delegation": { + "type": "boolean" + }, "system": { "type": "string" }, @@ -13748,6 +13970,9 @@ "type": "boolean" } }, + "delegation": { + "type": "boolean" + }, "system": { "type": "string" }, @@ -28822,6 +29047,9 @@ "type": "boolean" } }, + "delegation": { + "type": "boolean" + }, "variant": { "type": "string" }, @@ -30987,6 +31215,9 @@ }, "archived": { "type": "number" + }, + "pinned": { + "type": "number" } }, "required": [