From e8aa2d4c5434a5a309a548e569e25f490025dc6a Mon Sep 17 00:00:00 2001 From: zrr1999 <2742392377@qq.com> Date: Thu, 30 Jul 2026 13:37:52 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cockpit):=20retire=20legacy=20?= =?UTF-8?q?session=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/legacy-route-navigation.ts | 328 +++++++++ apps/spark-cockpit/e2e/tsconfig.json | 8 + apps/spark-cockpit/package.json | 3 +- apps/spark-cockpit/src/lib/LoginPage.svelte | 131 ++++ .../src/lib/TokenManagementSurface.svelte | 204 ++++++ .../spark-cockpit/src/lib/access-page.test.ts | 2 +- .../conversation-part-converters.ts | 280 ++++++++ .../conversation/conversation-view.test.ts | 62 ++ .../conversation/conversation-view.ts | 275 +------ .../src/lib/login-route-boundary.test.ts | 36 + .../src/lib/registration-page.test.ts | 4 +- .../canonical-session-route-boundary.test.ts | 57 +- .../server/canonical-workspace-route.test.ts | 39 + .../lib/server/canonical-workspace-route.ts | 14 + .../server/legacy-session-redirects.test.ts | 108 +++ .../session-conversation-actions.test.ts | 2 +- .../src/lib/server/session-detail-route.ts | 67 ++ .../src/lib/server/session-page-routes.ts | 651 +++++++++++++++++ .../workbench-layout-session-scope.test.ts | 6 +- .../lib/server/workbench-session-page.test.ts | 4 +- .../src/lib/session-live-events.ts | 425 ++++++----- .../spark-cockpit/src/lib/session-timeline.ts | 81 ++- .../src/lib/shell/shell-nav-link.css | 68 ++ .../src/lib/workbench-nav.test.ts | 18 + apps/spark-cockpit/src/lib/workbench-nav.ts | 15 + .../src/routes/(console)/+layout.svelte | 89 +-- .../settings/registration/+page.svelte | 109 +-- .../(console)/settings/access/+page.svelte | 199 ++---- .../(public)/[workspaceId]/login/+page.svelte | 113 +-- .../src/routes/(workbench)/+layout.server.ts | 52 +- .../src/routes/(workbench)/+layout.svelte | 89 +-- .../[workspaceId]/sessions/+page.server.ts | 13 +- .../sessions/[sessionId]/+page.server.ts | 15 +- .../(workbench)/sessions/+page.server.ts | 668 +----------------- .../routes/(workbench)/sessions/+page.svelte | 28 - .../sessions/[sessionId]/+page.server.ts | 100 +-- .../sessions/[sessionId]/+page.svelte | 31 - .../src/routes/login/+page.svelte | 113 +-- 38 files changed, 2564 insertions(+), 1943 deletions(-) create mode 100644 apps/spark-cockpit/e2e/legacy-route-navigation.ts create mode 100644 apps/spark-cockpit/e2e/tsconfig.json create mode 100644 apps/spark-cockpit/src/lib/LoginPage.svelte create mode 100644 apps/spark-cockpit/src/lib/TokenManagementSurface.svelte create mode 100644 apps/spark-cockpit/src/lib/components/conversation/conversation-part-converters.ts create mode 100644 apps/spark-cockpit/src/lib/login-route-boundary.test.ts create mode 100644 apps/spark-cockpit/src/lib/server/canonical-workspace-route.test.ts create mode 100644 apps/spark-cockpit/src/lib/server/canonical-workspace-route.ts create mode 100644 apps/spark-cockpit/src/lib/server/legacy-session-redirects.test.ts create mode 100644 apps/spark-cockpit/src/lib/server/session-detail-route.ts create mode 100644 apps/spark-cockpit/src/lib/server/session-page-routes.ts create mode 100644 apps/spark-cockpit/src/lib/shell/shell-nav-link.css delete mode 100644 apps/spark-cockpit/src/routes/(workbench)/sessions/+page.svelte delete mode 100644 apps/spark-cockpit/src/routes/(workbench)/sessions/[sessionId]/+page.svelte diff --git a/apps/spark-cockpit/e2e/legacy-route-navigation.ts b/apps/spark-cockpit/e2e/legacy-route-navigation.ts new file mode 100644 index 00000000..f612cbe3 --- /dev/null +++ b/apps/spark-cockpit/e2e/legacy-route-navigation.ts @@ -0,0 +1,328 @@ +/// + +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { createCockpitAccessToken } from "@zendev-lab/spark-cockpit-coordination/cockpit-access"; +import { createWorkspaceAccessToken } from "@zendev-lab/spark-cockpit-coordination/workspace-access"; +import { defaultDatabasePath, migrate, openDatabase } from "@zendev-lab/spark-cockpit-db"; +import { + chromium, + request, + type APIRequestContext, + type ConsoleMessage, + type Page, +} from "playwright"; + +const host = "127.0.0.1"; +const workspaceId = "ws_e2e_route_proof"; +const workspaceSlug = "route-proof"; +const sessionId = "runtime/ops"; +const now = "2026-07-30T00:00:00.000Z"; +const maxDiagnosticBytes = 16_384; + +const root = await mkdtemp(join(tmpdir(), "spark-cockpit-route-e2e-")); +let server: ChildProcess | undefined; +let browser: Awaited> | undefined; +let api: APIRequestContext | undefined; +const browserErrors: string[] = []; +const serverOutput = { stdout: "", stderr: "" }; + +try { + const databasePath = withIsolatedEnvironment(root, () => defaultDatabasePath()); + const db = openDatabase({ path: databasePath }); + seedDatabase(db); + db.close(); + + const port = await freePort(); + server = spawn("vp", ["dev", "--host", host, "--port", String(port)], { + detached: true, + cwd: join(import.meta.dirname, ".."), + env: { ...process.env, HOST: host, PORT: String(port), SPARK_HOME: root, HOME: root }, + stdio: ["ignore", "pipe", "pipe"], + }); + captureServerOutput(server, serverOutput); + + const baseUrl = `http://${host}:${port}`; + await waitForHttp(`${baseUrl}/login`, server, serverOutput); + api = await request.newContext(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + attachBrowserErrors(page, browserErrors); + + const list = await navigate(page, api, `${baseUrl}/sessions?workspace=${workspaceSlug}`); + assertEqual(list.status, 200, "legacy list response status"); + assertEqual( + list.finalUrl, + `${baseUrl}/${workspaceSlug}/sessions?workspace=${workspaceSlug}`, + "legacy list final URL", + ); + + const detail = await navigate( + page, + api, + `${baseUrl}/sessions/${encodeURIComponent(sessionId)}?tab=activity`, + ); + assertEqual(detail.status, 200, "legacy detail response status"); + assertEqual( + detail.finalUrl, + `${baseUrl}/${workspaceSlug}/sessions/${encodeURIComponent(sessionId)}?tab=activity`, + "legacy detail final URL", + ); + + const rootLogin = await navigate(page, api, `${baseUrl}/login?next=%2Fsettings`); + assertEqual(rootLogin.status, 200, "root login response status"); + assertEqual(rootLogin.finalUrl, `${baseUrl}/login?next=%2Fsettings`, "root login final URL"); + assertIncludes(await page.locator("body").innerText(), "Cockpit access", "root login body"); + + const workspaceLogin = await navigate( + page, + api, + `${baseUrl}/${workspaceSlug}/login?next=%2F${workspaceSlug}%2Fsessions`, + ); + assertEqual(workspaceLogin.status, 200, "workspace login response status"); + assertEqual( + workspaceLogin.finalUrl, + `${baseUrl}/${workspaceSlug}/login?next=%2F${workspaceSlug}%2Fsessions`, + "workspace login final URL", + ); + assertIncludes( + await page.locator("body").innerText(), + "Workspace access", + "workspace login body", + ); + + if (browserErrors.length > 0) { + throw new Error(`Browser errors:\n${browserErrors.join("\n")}`); + } + console.log( + JSON.stringify( + { list, detail, rootLogin, workspaceLogin, browserErrors: browserErrors.length }, + null, + 2, + ), + ); +} catch (error) { + throw withServerDiagnostics(error, server, serverOutput); +} finally { + await api?.dispose(); + await browser?.close(); + if (server) await stopProcess(server); + await rm(root, { recursive: true, force: true }); +} + +function withIsolatedEnvironment(sparkHome: string, operation: () => T): T { + const previousSparkHome = process.env.SPARK_HOME; + const previousHome = process.env.HOME; + process.env.SPARK_HOME = sparkHome; + process.env.HOME = sparkHome; + try { + return operation(); + } finally { + restoreEnvironment("SPARK_HOME", previousSparkHome); + restoreEnvironment("HOME", previousHome); + } +} + +function restoreEnvironment(name: "SPARK_HOME" | "HOME", value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +function seedDatabase(db: DatabaseSync): void { + migrate(db); + db.prepare( + `INSERT INTO workspaces (id, slug, name, status, settings_json, created_at, updated_at) + VALUES (?, ?, ?, 'active', '{}', ?, ?)`, + ).run(workspaceId, workspaceSlug, "Route Proof", now, now); + createCockpitAccessToken(db, { createdAt: now, ttlMs: 3_600_000 }); + createWorkspaceAccessToken(db, { workspaceId, createdAt: now, ttlMs: 3_600_000 }); + + const runtimeId = "rt_e2e_route_proof"; + const bindingId = "rtwb_e2e_route_proof"; + db.prepare( + `INSERT INTO runtime_connections + (id, installation_id, name, status, protocol_version, capabilities_json, labels_json, created_at, updated_at) + VALUES (?, 'install-e2e-route-proof', 'E2E runtime', 'online', '1', '{}', '{}', ?, ?)`, + ).run(runtimeId, now, now); + db.prepare( + `INSERT INTO runtime_sessions (id, runtime_id, transport, status, connected_at, last_seen_at) + VALUES ('rtsn_e2e_route_proof', ?, 'websocket', 'connected', ?, ?)`, + ).run(runtimeId, now, now); + db.prepare( + `INSERT INTO runtime_workspace_bindings + (id, runtime_id, local_workspace_key, display_name, status, capabilities_json, diagnostics_json, created_at, updated_at) + VALUES (?, ?, 'route-proof', 'Route Proof', 'available', '{}', '{}', ?, ?)`, + ).run(bindingId, runtimeId, now, now); + db.prepare( + `INSERT INTO workspace_leases + (id, workspace_id, runtime_workspace_binding_id, owner_mode, started_at, created_at) + VALUES ('wob_e2e_route_proof', ?, ?, 'primary', ?, ?)`, + ).run(workspaceId, bindingId, now, now); + db.prepare( + `INSERT INTO runtime_session_projections + (runtime_id, session_id, scope, workspace_id, runtime_workspace_binding_id, status, record_json, projected_at) + VALUES (?, ?, 'workspace', ?, ?, 'ready', ?, ?)`, + ).run( + runtimeId, + sessionId, + workspaceId, + bindingId, + JSON.stringify({ + sessionId, + scope: { kind: "workspace", workspaceId }, + workspaceId, + title: "Route proof", + status: "ready", + bindings: [], + createdAt: now, + updatedAt: now, + }), + now, + ); +} + +interface RedirectHop { + status: number; + url: string; + location: string | null; +} + +async function navigate(page: Page, api: APIRequestContext, url: string) { + const chain = await redirectChain(api, url); + const response = await page.goto(url, { waitUntil: "domcontentloaded" }); + return { + status: response?.status() ?? 0, + finalUrl: page.url(), + title: await page.title(), + redirects: chain, + }; +} + +async function redirectChain(api: APIRequestContext, initialUrl: string): Promise { + const hops: RedirectHop[] = []; + let url = initialUrl; + for (let index = 0; index < 10; index += 1) { + const response = await api.get(url, { maxRedirects: 0 }); + const location = response.headers().location ?? null; + hops.push({ status: response.status(), url, location }); + if (!location || response.status() < 300 || response.status() >= 400) return hops; + url = new URL(location, url).toString(); + } + throw new Error(`Redirect chain exceeded 10 hops: ${JSON.stringify(hops)}`); +} + +function attachBrowserErrors(page: Page, target: string[]): void { + page.on("console", (message: ConsoleMessage) => { + if (message.type() === "error") target.push(`console.error: ${message.text()}`); + }); + page.on("pageerror", (error) => target.push(`pageerror: ${error.message}`)); +} + +async function waitForHttp( + url: string, + child: ChildProcess, + output: { stdout: string; stderr: string }, +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + assertServerRunning(child, output); + try { + const response = await fetch(url); + if (response.status > 0) return; + } catch { + // Server is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`Timed out waiting for Cockpit HTTP readiness: ${url}`); +} + +function captureServerOutput( + child: ChildProcess, + output: { stdout: string; stderr: string }, +): void { + child.stdout?.on("data", (chunk: Buffer) => { + const text = String(chunk); + output.stdout = boundedAppend(output.stdout, text); + process.stdout.write(text); + }); + child.stderr?.on("data", (chunk: Buffer) => { + const text = String(chunk); + output.stderr = boundedAppend(output.stderr, text); + process.stderr.write(text); + }); +} + +function boundedAppend(current: string, added: string): string { + return `${current}${added}`.slice(-maxDiagnosticBytes); +} + +function assertServerRunning( + child: ChildProcess, + output: { stdout: string; stderr: string }, +): void { + if (child.exitCode === null && child.signalCode === null) return; + throw new Error( + `Cockpit server exited before readiness (code=${String(child.exitCode)}, signal=${String(child.signalCode)})\n${renderServerOutput(output)}`, + ); +} + +function withServerDiagnostics( + error: unknown, + child: ChildProcess | undefined, + output: { stdout: string; stderr: string }, +): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error( + `${message}\nCockpit server: code=${String(child?.exitCode)}, signal=${String(child?.signalCode)}\n${renderServerOutput(output)}`, + { cause: error }, + ); +} + +function renderServerOutput(output: { stdout: string; stderr: string }): string { + return `--- server stdout ---\n${output.stdout}\n--- server stderr ---\n${output.stderr}`; +} + +async function freePort(): Promise { + return await new Promise((resolve, reject) => { + const listener = createServer(); + listener.once("error", reject); + listener.listen(0, host, () => { + const address = listener.address(); + listener.close(() => resolve(typeof address === "object" && address ? address.port : 0)); + }); + }); +} + +async function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM"); + else child.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + resolve(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +function assertIncludes(actual: string, expected: string, label: string): void { + if (!actual.toLocaleLowerCase().includes(expected.toLocaleLowerCase())) { + throw new Error(`${label}: expected body to include ${expected}, got ${actual.slice(0, 500)}`); + } +} + +function assertEqual(actual: unknown, expected: unknown, label: string): void { + if (actual !== expected) { + throw new Error(`${label}: expected ${String(expected)}, got ${String(actual)}`); + } +} diff --git a/apps/spark-cockpit/e2e/tsconfig.json b/apps/spark-cockpit/e2e/tsconfig.json new file mode 100644 index 00000000..50a40c4c --- /dev/null +++ b/apps/spark-cockpit/e2e/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["./legacy-route-navigation.ts"] +} diff --git a/apps/spark-cockpit/package.json b/apps/spark-cockpit/package.json index 726ecf0f..7a4c3049 100644 --- a/apps/spark-cockpit/package.json +++ b/apps/spark-cockpit/package.json @@ -20,7 +20,8 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "setup:browser": "playwright install --with-deps chromium", "test": "vp test run", - "test:browser": "vp test run --config vitest.browser.config.ts" + "test:e2e:cockpit": "tsx e2e/legacy-route-navigation.ts", + "test:browser": "vp test run --config vitest.browser.config.ts && tsx e2e/legacy-route-navigation.ts" }, "dependencies": { "@lucide/svelte": "^1.16.0", diff --git a/apps/spark-cockpit/src/lib/LoginPage.svelte b/apps/spark-cockpit/src/lib/LoginPage.svelte new file mode 100644 index 00000000..450b5962 --- /dev/null +++ b/apps/spark-cockpit/src/lib/LoginPage.svelte @@ -0,0 +1,131 @@ + + + + {copy.headTitle} + + + + + diff --git a/apps/spark-cockpit/src/lib/TokenManagementSurface.svelte b/apps/spark-cockpit/src/lib/TokenManagementSurface.svelte new file mode 100644 index 00000000..bb85927b --- /dev/null +++ b/apps/spark-cockpit/src/lib/TokenManagementSurface.svelte @@ -0,0 +1,204 @@ + + +
+ {#if heading} +
+
+

{heading}

+ {#if body}

{body}

{/if} +
+ {#if icon}{/if} +
+ {/if} + +
+ + + + +
+ + {#if created} + {@render created()} + {:else if message} +

{message}

+ {/if} + +
+
+

{tableTitle}

+ {tableCount} +
+ {#if hasTokens} +
{@render tokens()}
+ {:else} +
+ +
+ {emptyTitle} +

{emptyBody}

+
+
+ {/if} +
+
+ + diff --git a/apps/spark-cockpit/src/lib/access-page.test.ts b/apps/spark-cockpit/src/lib/access-page.test.ts index 42d2b3d3..bcfa0d5e 100644 --- a/apps/spark-cockpit/src/lib/access-page.test.ts +++ b/apps/spark-cockpit/src/lib/access-page.test.ts @@ -25,7 +25,7 @@ describe("cockpit browser access page", () => { expect(server).toContain("listCockpitAccessTokens"); expect(server).toContain('new URL("/login"'); expect(server).not.toContain("createWorkspaceAccessToken"); - expect(page).toContain('action="?/createAccessToken"'); + expect(page).toMatch(/; +type PartContext = { message: SparkMessageView; index: number; partType: string }; +type PartParser = (value: UnknownRecord, context: PartContext) => ConversationPart[]; + +const partParsers: Readonly> = { + text: parseTextPart, + image: parseImagePart, + thinking: parseReasoningPart, + reasoning: parseReasoningPart, + "tool-call": parseToolPart, + "tool-result": parseToolPart, + tool: parseToolPart, + task: parseTaskPart, + approval: parseApprovalPart, + artifact: parseArtifactPart, + error: parseErrorPart, +}; + +export function normalizeConversationPart( + value: unknown, + message: SparkMessageView, + index: number, +): ConversationPart[] { + if (!isRecord(value)) return []; + const partType = stringField(value, "type"); + if (!partType) return []; + const parser = partParsers[partType]; + return parser + ? parser(value, { message, index, partType }) + : [{ type: "unknown", label: boundedLabel(partType) }]; +} + +function parseTextPart(value: UnknownRecord, { message }: PartContext): ConversationPart[] { + const text = stringField(value, "text"); + if (!text?.trim()) return []; + const streaming = stringField(value, "status") === "streaming" || message.status === "streaming"; + return stringField(value, "phase") === "commentary" + ? [{ type: "commentary", summary: text, state: streaming ? "streaming" : "complete" }] + : [{ type: "text", text, streaming }]; +} + +function parseImagePart(value: UnknownRecord): ConversationPart[] { + if ( + typeof value.contentIndex !== "number" || + !Number.isSafeInteger(value.contentIndex) || + value.contentIndex < 0 || + !isRenderableImageMediaType(value.mediaType) + ) { + return []; + } + return [ + { + type: "image", + contentIndex: value.contentIndex, + mediaType: value.mediaType, + ...(typeof value.name === "string" && value.name.trim() ? { name: value.name.trim() } : {}), + }, + ]; +} + +function parseReasoningPart(value: UnknownRecord, { message }: PartContext): ConversationPart[] { + const redacted = value.redacted === true; + const summary = redacted ? "" : (stringField(value, "summary") ?? stringField(value, "text")); + if (!summary?.trim() && !redacted) return []; + return [ + { + type: "reasoning", + summary: summary ?? "", + state: + stringField(value, "status") === "streaming" || message.status === "streaming" + ? "streaming" + : "complete", + redacted, + }, + ]; +} + +function parseToolPart(value: UnknownRecord, context: PartContext): ConversationPart[] { + const { message, index, partType } = context; + const callId = + stringField(value, "callId") ?? + stringField(value, "toolCallId") ?? + message.toolCallId ?? + `${message.id}:tool:${index}`; + const name = + stringField(value, "name") ?? stringField(value, "toolName") ?? message.toolName ?? "tool"; + const summary = + stringField(value, "summary") ?? + stringField(value, "text") ?? + (message.role === "tool" && message.text.trim() ? message.text.trim() : undefined) ?? + (partType === "tool-result" && message.text.trim() ? message.text.trim() : undefined); + return [ + { + type: "tool", + callId, + name, + state: toolState(stringField(value, "status") ?? message.status, partType), + ...(summary ? { summary } : {}), + }, + ]; +} + +function parseTaskPart(value: UnknownRecord, { message, index }: PartContext): ConversationPart[] { + const taskRef = stringField(value, "taskRef") ?? `${message.id}:task:${index}`; + return [ + { + type: "task", + taskRef, + title: stringField(value, "title") ?? taskRef, + state: taskState(stringField(value, "status")), + summary: stringField(value, "summary"), + }, + ]; +} + +function parseApprovalPart( + value: UnknownRecord, + { message, index }: PartContext, +): ConversationPart[] { + const requestId = stringField(value, "requestId") ?? `${message.id}:approval:${index}`; + return [ + { + type: "approval", + requestId, + title: stringField(value, "title") ?? requestId, + state: approvalState(stringField(value, "status")), + kind: stringField(value, "kind"), + summary: stringField(value, "summary"), + }, + ]; +} + +function parseArtifactPart( + value: UnknownRecord, + { message, index }: PartContext, +): ConversationPart[] { + const artifactRef = + stringField(value, "artifactRef") ?? + stringField(value, "artifactId") ?? + stringField(value, "ref") ?? + `${message.id}:artifact:${index}`; + return [ + { + type: "artifact", + artifactRef, + title: stringField(value, "title") ?? artifactRef, + kind: stringField(value, "kind"), + state: stringField(value, "state") ?? stringField(value, "status"), + summary: stringField(value, "summary"), + }, + ]; +} + +function parseErrorPart(value: UnknownRecord): ConversationPart[] { + const title = stringField(value, "title") ?? "Error"; + return [ + { + type: "error", + title, + message: + stringField(value, "message") ?? + stringField(value, "summary") ?? + stringField(value, "text") ?? + title, + code: stringField(value, "code"), + }, + ]; +} + +export function mergeToolParts(parts: readonly ConversationPart[]): ConversationPart[] { + const result: ConversationPart[] = []; + const toolIndexes = new Map(); + for (const part of parts) { + if (part.type !== "tool") { + result.push(part); + continue; + } + const previousIndex = toolIndexes.get(part.callId); + const previous = previousIndex === undefined ? undefined : result[previousIndex]; + if (previousIndex === undefined || previous?.type !== "tool") { + toolIndexes.set(part.callId, result.length); + result.push(part); + continue; + } + result[previousIndex] = { + ...previous, + name: part.name || previous.name, + state: laterToolState(previous.state, part.state), + summary: preferToolSummary(previous.summary, part.summary, previous.state, part.state), + }; + } + return result; +} + +export function laterToolState( + previous: ConversationToolState, + next: ConversationToolState, +): ConversationToolState { + const rank: Record = { + pending: 0, + "awaiting-approval": 1, + running: 2, + completed: 3, + denied: 3, + cancelled: 3, + failed: 4, + }; + return rank[next] >= rank[previous] ? next : previous; +} + +export function preferToolSummary( + previous: string | undefined, + next: string | undefined, + previousState: ConversationToolState, + nextState: ConversationToolState, +): string | undefined { + const terminal = new Set(["completed", "failed", "denied", "cancelled"]); + if (terminal.has(nextState) && next?.trim()) return next.trim(); + if (terminal.has(previousState) && previous?.trim()) return previous.trim(); + return next?.trim() || previous?.trim() || undefined; +} + +export function taskState(value: string | undefined): ConversationTaskState { + if (["completed", "complete", "done", "succeeded", "success"].includes(value ?? "")) + return "completed"; + if (["failed", "error"].includes(value ?? "")) return "failed"; + if (value === "blocked") return "blocked"; + if (["cancelled", "canceled"].includes(value ?? "")) return "cancelled"; + if (["running", "in_progress", "claimed"].includes(value ?? "")) return "running"; + return "pending"; +} + +export function approvalState(value: string | undefined): ConversationApprovalState { + if (["approved", "accepted"].includes(value ?? "")) return "approved"; + if (["answered", "resolved", "completed", "complete", "done"].includes(value ?? "")) + return "resolved"; + if (["rejected", "denied"].includes(value ?? "")) return "rejected"; + if (["cancelled", "canceled"].includes(value ?? "")) return "cancelled"; + return "requested"; +} + +export function toolState(value: string | undefined, partType: string): ConversationToolState { + if (value === "awaiting-approval") return "awaiting-approval"; + if (["completed", "complete", "done", "succeeded", "success"].includes(value ?? "")) + return "completed"; + if (["failed", "error"].includes(value ?? "")) return "failed"; + if (["denied", "rejected"].includes(value ?? "")) return "denied"; + if (["cancelled", "canceled"].includes(value ?? "")) return "cancelled"; + if (["running", "streaming"].includes(value ?? "")) return "running"; + return partType === "tool-result" ? "completed" : "pending"; +} + +function isRenderableImageMediaType( + value: unknown, +): value is Extract["mediaType"] { + return ["image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp"].includes( + String(value), + ); +} + +function isRecord(value: unknown): value is UnknownRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringField(value: UnknownRecord, key: string): string | undefined { + const candidate = value[key]; + return typeof candidate === "string" && candidate.trim() ? candidate : undefined; +} + +function boundedLabel(value: string): string { + return value.length <= 80 ? value : `${value.slice(0, 77)}…`; +} diff --git a/apps/spark-cockpit/src/lib/components/conversation/conversation-view.test.ts b/apps/spark-cockpit/src/lib/components/conversation/conversation-view.test.ts index 946d0ac4..39b271ed 100644 --- a/apps/spark-cockpit/src/lib/components/conversation/conversation-view.test.ts +++ b/apps/spark-cockpit/src/lib/components/conversation/conversation-view.test.ts @@ -490,6 +490,68 @@ describe("Cockpit conversation view adapter", () => { ]); expect(JSON.stringify(parts)).not.toContain("must-not-render"); }); + + it("handles parser aliases, unknown values, invalid records, and empty parts deterministically", () => { + const parts = conversationPartsFromMessage( + message({ + text: "", + parts: [ + null, + {}, + { type: "text", text: "" }, + { type: "thinking", summary: "Thinking alias", status: "complete" }, + { type: "reasoning", summary: "Reasoning alias", status: "complete" }, + { type: "future-part-with-a-long-but-bounded-label" }, + ] as unknown as SparkMessageView["parts"], + }), + ); + + expect(parts).toEqual([ + { type: "reasoning", summary: "Thinking alias", state: "complete", redacted: false }, + { type: "reasoning", summary: "Reasoning alias", state: "complete", redacted: false }, + { type: "unknown", label: "future-part-with-a-long-but-bounded-label" }, + ]); + }); + + it("keeps anonymous structured part identities unique and in daemon order", () => { + const parts = conversationPartsFromMessage( + message({ + id: "message-anonymous", + text: "", + parts: [ + { type: "tool", name: "first", status: "running" }, + { type: "tool", name: "second", status: "running" }, + { type: "task", title: "Task one" }, + { type: "task", title: "Task two" }, + { type: "approval", title: "Approval one" }, + { type: "approval", title: "Approval two" }, + { type: "artifact", title: "Artifact one" }, + { type: "artifact", title: "Artifact two" }, + ] as unknown as SparkMessageView["parts"], + }), + ); + + expect(parts.map((part) => part.type)).toEqual([ + "tool", + "tool", + "task", + "task", + "approval", + "approval", + "artifact", + "artifact", + ]); + expect(parts).toMatchObject([ + { type: "tool", callId: "message-anonymous:tool:0", name: "first" }, + { type: "tool", callId: "message-anonymous:tool:1", name: "second" }, + { type: "task", taskRef: "message-anonymous:task:2" }, + { type: "task", taskRef: "message-anonymous:task:3" }, + { type: "approval", requestId: "message-anonymous:approval:4" }, + { type: "approval", requestId: "message-anonymous:approval:5" }, + { type: "artifact", artifactRef: "message-anonymous:artifact:6" }, + { type: "artifact", artifactRef: "message-anonymous:artifact:7" }, + ]); + }); }); function message( diff --git a/apps/spark-cockpit/src/lib/components/conversation/conversation-view.ts b/apps/spark-cockpit/src/lib/components/conversation/conversation-view.ts index 0850c9d4..dc0f5fbb 100644 --- a/apps/spark-cockpit/src/lib/components/conversation/conversation-view.ts +++ b/apps/spark-cockpit/src/lib/components/conversation/conversation-view.ts @@ -1,13 +1,14 @@ import type { SparkMessageView } from "@zendev-lab/spark-protocol"; -import type { - ConversationApprovalState, - ConversationChainStep, - ConversationPart, - ConversationTaskState, - ConversationToolState, -} from "./types"; +import type { ConversationChainStep, ConversationPart } from "./types"; import { isInternalExecutionTransportFailure } from "./internal-execution-detail"; import { isVisibleThinkingChain } from "./thinking-chain-view"; +import { + mergeToolParts, + normalizeConversationPart, + toolState, +} from "./conversation-part-converters"; + +export { preferToolSummary } from "./conversation-part-converters"; type UnknownRecord = Record; @@ -17,7 +18,7 @@ export function conversationPartsFromMessage( ): ConversationPart[] { const messageRecord = message as SparkMessageView & { parts?: unknown }; const rawParts = Array.isArray(messageRecord.parts) ? messageRecord.parts : []; - let parts = mergeMessageToolParts( + let parts = mergeToolParts( rawParts.flatMap((part, index) => normalizePart(part, message, index)), ); @@ -208,149 +209,7 @@ function normalizePart( message: SparkMessageView, index: number, ): ConversationPart[] { - if (!isRecord(value)) return []; - const type = stringField(value, "type"); - if (!type) return []; - - if (type === "text") { - const text = stringField(value, "text"); - const streaming = - stringField(value, "status") === "streaming" || message.status === "streaming"; - if (text?.trim() && stringField(value, "phase") === "commentary") { - return [{ type: "commentary", summary: text, state: streaming ? "streaming" : "complete" }]; - } - return text?.trim() - ? [ - { - type: "text", - text, - streaming, - }, - ] - : []; - } - - if ( - type === "image" && - typeof value.contentIndex === "number" && - Number.isSafeInteger(value.contentIndex) && - value.contentIndex >= 0 && - isRenderableImageMediaType(value.mediaType) - ) { - return [ - { - type: "image", - contentIndex: value.contentIndex, - mediaType: value.mediaType, - ...(typeof value.name === "string" && value.name.trim() ? { name: value.name.trim() } : {}), - }, - ]; - } - - if (type === "thinking" || type === "reasoning") { - const redacted = value.redacted === true; - const summary = redacted ? "" : (stringField(value, "summary") ?? stringField(value, "text")); - return summary?.trim() || redacted - ? [ - { - type: "reasoning", - summary: summary ?? "", - state: - stringField(value, "status") === "streaming" || message.status === "streaming" - ? "streaming" - : "complete", - redacted, - }, - ] - : []; - } - - if (type === "tool-call" || type === "tool-result" || type === "tool") { - const callId = - stringField(value, "callId") ?? - stringField(value, "toolCallId") ?? - message.toolCallId ?? - `${message.id}:tool:${index}`; - const name = - stringField(value, "name") ?? stringField(value, "toolName") ?? message.toolName ?? "tool"; - const summary = - stringField(value, "summary") ?? - stringField(value, "text") ?? - (message.role === "tool" && message.text.trim() ? message.text.trim() : undefined) ?? - (type === "tool-result" && message.text.trim() ? message.text.trim() : undefined); - return [ - { - type: "tool", - callId, - name, - state: toolState(stringField(value, "status") ?? message.status, type), - ...(summary ? { summary } : {}), - }, - ]; - } - - if (type === "task") { - const taskRef = stringField(value, "taskRef") ?? `${message.id}:task:${index}`; - return [ - { - type: "task", - taskRef, - title: stringField(value, "title") ?? taskRef, - state: taskState(stringField(value, "status")), - summary: stringField(value, "summary"), - }, - ]; - } - - if (type === "approval") { - const requestId = stringField(value, "requestId") ?? `${message.id}:approval:${index}`; - return [ - { - type: "approval", - requestId, - title: stringField(value, "title") ?? requestId, - state: approvalState(stringField(value, "status")), - kind: stringField(value, "kind"), - summary: stringField(value, "summary"), - }, - ]; - } - - if (type === "artifact") { - const artifactRef = - stringField(value, "artifactRef") ?? - stringField(value, "artifactId") ?? - stringField(value, "ref") ?? - `${message.id}:artifact:${index}`; - return [ - { - type: "artifact", - artifactRef, - title: stringField(value, "title") ?? artifactRef, - kind: stringField(value, "kind"), - state: stringField(value, "state") ?? stringField(value, "status"), - summary: stringField(value, "summary"), - }, - ]; - } - - if (type === "error") { - const title = stringField(value, "title") ?? "Error"; - return [ - { - type: "error", - title, - message: - stringField(value, "message") ?? - stringField(value, "summary") ?? - stringField(value, "text") ?? - title, - code: stringField(value, "code"), - }, - ]; - } - - return [{ type: "unknown", label: boundedLabel(type) }]; + return normalizeConversationPart(value, message, index); } function stripRenderedImagePlaceholders(parts: ConversationPart[]): ConversationPart[] { @@ -372,18 +231,6 @@ function isImagePlaceholder(value: string): boolean { return /^\s*(?:\[图片\]|\[image(?::[^\]]+)?\])\s*$/iu.test(value); } -function isRenderableImageMediaType( - value: unknown, -): value is Extract["mediaType"] { - return ( - value === "image/bmp" || - value === "image/gif" || - value === "image/jpeg" || - value === "image/png" || - value === "image/webp" - ); -} - function fallbackParts(message: SparkMessageView, displayText: string): ConversationPart[] { if (!displayText.trim()) return []; if (message.role === "thinking") { @@ -409,109 +256,7 @@ function fallbackParts(message: SparkMessageView, displayText: string): Conversa return [{ type: "text", text: displayText, streaming: message.status === "streaming" }]; } -function toolState(value: string | undefined, partType: string): ConversationToolState { - if (value === "awaiting-approval") return "awaiting-approval"; - if (["completed", "complete", "done", "succeeded", "success"].includes(value ?? "")) { - return "completed"; - } - if (["failed", "error"].includes(value ?? "")) return "failed"; - if (["denied", "rejected"].includes(value ?? "")) return "denied"; - if (["cancelled", "canceled"].includes(value ?? "")) return "cancelled"; - if (["running", "streaming"].includes(value ?? "")) return "running"; - if (partType === "tool-result") return "completed"; - return "pending"; -} - -function taskState(value: string | undefined): ConversationTaskState { - if (["completed", "complete", "done", "succeeded", "success"].includes(value ?? "")) { - return "completed"; - } - if (["failed", "error"].includes(value ?? "")) return "failed"; - if (value === "blocked") return "blocked"; - if (["cancelled", "canceled"].includes(value ?? "")) return "cancelled"; - if (["running", "in_progress", "claimed"].includes(value ?? "")) return "running"; - return "pending"; -} - -function approvalState(value: string | undefined): ConversationApprovalState { - if (["approved", "accepted"].includes(value ?? "")) return "approved"; - if (["answered", "resolved", "completed", "complete", "done"].includes(value ?? "")) { - return "resolved"; - } - if (["rejected", "denied"].includes(value ?? "")) return "rejected"; - if (["cancelled", "canceled"].includes(value ?? "")) return "cancelled"; - return "requested"; -} - -function mergeMessageToolParts(parts: ConversationPart[]) { - const result: ConversationPart[] = []; - const toolIndexes = new Map(); - - for (const part of parts) { - if (part.type !== "tool") { - result.push(part); - continue; - } - const previousIndex = toolIndexes.get(part.callId); - const previous = previousIndex === undefined ? undefined : result[previousIndex]; - if (previousIndex === undefined || previous?.type !== "tool") { - toolIndexes.set(part.callId, result.length); - result.push(part); - continue; - } - result[previousIndex] = { - ...previous, - name: part.name || previous.name, - state: laterToolState(previous.state, part.state), - summary: preferToolSummary(previous.summary, part.summary, previous.state, part.state), - }; - } - - return result; -} - -/** Prefer completed/failed result text over call-argument previews. */ -export function preferToolSummary( - previous: string | undefined, - next: string | undefined, - previousState: ConversationToolState, - nextState: ConversationToolState, -): string | undefined { - const nextIsResult = - nextState === "completed" || - nextState === "failed" || - nextState === "denied" || - nextState === "cancelled"; - const previousIsResult = - previousState === "completed" || - previousState === "failed" || - previousState === "denied" || - previousState === "cancelled"; - if (nextIsResult && next?.trim()) return next.trim(); - if (previousIsResult && previous?.trim()) return previous.trim(); - if (next?.trim()) return next.trim(); - if (previous?.trim()) return previous.trim(); - return undefined; -} - -function laterToolState(previous: ConversationToolState, next: ConversationToolState) { - const rank: Record = { - pending: 0, - "awaiting-approval": 1, - running: 2, - completed: 3, - denied: 3, - cancelled: 3, - failed: 4, - }; - return rank[next] >= rank[previous] ? next : previous; -} - function stringField(value: UnknownRecord, key: string) { const candidate = value[key]; return typeof candidate === "string" && candidate.trim() ? candidate : undefined; } - -function boundedLabel(value: string) { - return value.length <= 80 ? value : `${value.slice(0, 77)}…`; -} diff --git a/apps/spark-cockpit/src/lib/login-route-boundary.test.ts b/apps/spark-cockpit/src/lib/login-route-boundary.test.ts new file mode 100644 index 00000000..7c70fe1c --- /dev/null +++ b/apps/spark-cockpit/src/lib/login-route-boundary.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const appRoot = join(import.meta.dirname, ".."); +const readAppFile = (path: string) => readFileSync(join(appRoot, path), "utf8"); + +describe("login route boundary", () => { + it("preserves separate root and workspace server auth policies", () => { + const rootServer = readAppFile("routes/login/+page.server.ts"); + const workspaceServer = readAppFile("routes/(public)/[workspaceId]/login/+page.server.ts"); + + expect(rootServer).toContain("exchangeCockpitAccessToken"); + expect(rootServer).toContain("getCurrentCockpitSession"); + expect(rootServer).not.toContain("exchangeWorkspaceAccessToken"); + expect(workspaceServer).toContain("exchangeWorkspaceAccessToken"); + expect(workspaceServer).toContain("getCurrentWorkspaceSession"); + expect(workspaceServer).not.toContain("exchangeCockpitAccessToken"); + }); + + it("renders both policies through the shared LoginPage without cloned HTML or CSS", () => { + const rootPage = readAppFile("routes/login/+page.svelte"); + const workspacePage = readAppFile("routes/(public)/[workspaceId]/login/+page.svelte"); + const sharedPage = readAppFile("lib/LoginPage.svelte"); + + for (const routePage of [rootPage, workspacePage]) { + expect(routePage).toContain('import LoginPage from "$lib/LoginPage.svelte"'); + expect(routePage).toContain(""); + } + expect(sharedPage).toContain('name="next"'); + expect(sharedPage).toContain("errorMessage"); + expect(sharedPage).toContain("disabled={!available}"); + }); +}); diff --git a/apps/spark-cockpit/src/lib/registration-page.test.ts b/apps/spark-cockpit/src/lib/registration-page.test.ts index e36bdd83..e6971741 100644 --- a/apps/spark-cockpit/src/lib/registration-page.test.ts +++ b/apps/spark-cockpit/src/lib/registration-page.test.ts @@ -32,7 +32,9 @@ describe("workspace registration page contract", () => { it("keeps daemon registration without workspace browser-access minting", () => { const source = readFileSync(pagePath, "utf8"); - expect(source).toContain('action="?/createEnrollmentToken"'); + expect(source).toMatch( + / ({ - getDatabase: vi.fn(() => ({})), + canonicalLoad: vi.fn(), listLoad: vi.fn(), detailLoad: vi.fn(), - requireWorkspaceByRouteId: vi.fn(), })); -vi.mock("$lib/server/db", () => ({ getDatabase: mocks.getDatabase })); -vi.mock("$lib/server/workspace-routing", () => ({ - requireWorkspaceByRouteId: mocks.requireWorkspaceByRouteId, +vi.mock("$lib/server/canonical-workspace-route", () => ({ + loadCanonicalWorkspaceRoute: mocks.canonicalLoad, })); -vi.mock("../../routes/(workbench)/sessions/+page.server", () => ({ +vi.mock("$lib/server/session-page-routes", () => ({ actions: {}, - _loadSessionsPage: mocks.listLoad, + loadSessionsPage: mocks.listLoad, })); -vi.mock("../../routes/(workbench)/sessions/[sessionId]/+page.server", () => ({ - _loadSessionPage: mocks.detailLoad, +vi.mock("$lib/server/session-detail-route", () => ({ + loadSessionPage: mocks.detailLoad, })); import { load as loadCanonicalList } from "../../routes/(workbench)/[workspaceId]/sessions/+page.server"; @@ -25,47 +23,26 @@ import { load as loadCanonicalDetail } from "../../routes/(workbench)/[workspace describe("canonical workspace session route boundary", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.requireWorkspaceByRouteId.mockReturnValue({ - id: "ws_demo", - slug: "demo", - name: "Demo", - }); - mocks.listLoad.mockResolvedValue({ selectedSessionId: null }); - mocks.detailLoad.mockResolvedValue({ selectedSessionId: "sess_demo" }); + mocks.canonicalLoad.mockImplementation(async (event, loader) => + loader === mocks.listLoad + ? { selectedSessionId: null, event } + : { selectedSessionId: "sess_demo", event }, + ); }); - it("resolves the active route workspace before loading list or detail data", async () => { + it("uses route-neutral canonical loaders instead of legacy routes", async () => { const listEvent = routeEvent("demo"); const detailEvent = routeEvent("demo", "sess_demo"); - await expect(loadCanonicalList(listEvent as never)).resolves.toEqual({ + await expect(loadCanonicalList(listEvent as never)).resolves.toMatchObject({ selectedSessionId: null, }); - await expect(loadCanonicalDetail(detailEvent as never)).resolves.toEqual({ + await expect(loadCanonicalDetail(detailEvent as never)).resolves.toMatchObject({ selectedSessionId: "sess_demo", }); - expect(mocks.listLoad).toHaveBeenCalledWith(listEvent, "ws_demo"); - expect(mocks.detailLoad).toHaveBeenCalledWith(detailEvent, "ws_demo"); - }); - - it("returns 404 for an unknown or archived route without loading parent state", async () => { - const routeError = Object.assign(new Error("Workspace not found."), { status: 404 }); - mocks.requireWorkspaceByRouteId.mockImplementation(() => { - throw routeError; - }); - const parent = vi.fn(); - - await expect( - loadCanonicalList({ ...routeEvent("archived"), parent } as never), - ).rejects.toMatchObject({ status: 404 }); - await expect( - loadCanonicalDetail({ ...routeEvent("missing", "sess_missing"), parent } as never), - ).rejects.toMatchObject({ status: 404 }); - - expect(parent).not.toHaveBeenCalled(); - expect(mocks.listLoad).not.toHaveBeenCalled(); - expect(mocks.detailLoad).not.toHaveBeenCalled(); + expect(mocks.canonicalLoad).toHaveBeenCalledWith(listEvent, mocks.listLoad); + expect(mocks.canonicalLoad).toHaveBeenCalledWith(detailEvent, mocks.detailLoad); }); }); diff --git a/apps/spark-cockpit/src/lib/server/canonical-workspace-route.test.ts b/apps/spark-cockpit/src/lib/server/canonical-workspace-route.test.ts new file mode 100644 index 00000000..aa0c2870 --- /dev/null +++ b/apps/spark-cockpit/src/lib/server/canonical-workspace-route.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getDatabase: vi.fn(() => ({})), + requireWorkspaceByRouteId: vi.fn(), +})); + +vi.mock("$lib/server/db", () => ({ getDatabase: mocks.getDatabase })); +vi.mock("$lib/server/workspace-routing", () => ({ + requireWorkspaceByRouteId: mocks.requireWorkspaceByRouteId, +})); + +import { loadCanonicalWorkspaceRoute } from "./canonical-workspace-route"; + +describe("canonical workspace route adapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireWorkspaceByRouteId.mockReturnValue({ id: "ws_demo", slug: "demo" }); + }); + + it("resolves the active workspace before invoking its route-neutral loader", async () => { + const event = { params: { workspaceId: "demo" } }; + const loader = vi.fn().mockResolvedValue({ ok: true }); + await expect(loadCanonicalWorkspaceRoute(event, loader)).resolves.toEqual({ ok: true }); + expect(loader).toHaveBeenCalledWith(event, "ws_demo"); + }); + + it("does not invoke the loader for an unknown or archived workspace", async () => { + const routeError = Object.assign(new Error("Workspace not found."), { status: 404 }); + mocks.requireWorkspaceByRouteId.mockImplementation(() => { + throw routeError; + }); + const loader = vi.fn(); + await expect( + loadCanonicalWorkspaceRoute({ params: { workspaceId: "missing" } }, loader), + ).rejects.toMatchObject({ status: 404 }); + expect(loader).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/spark-cockpit/src/lib/server/canonical-workspace-route.ts b/apps/spark-cockpit/src/lib/server/canonical-workspace-route.ts new file mode 100644 index 00000000..2549a33a --- /dev/null +++ b/apps/spark-cockpit/src/lib/server/canonical-workspace-route.ts @@ -0,0 +1,14 @@ +import { getDatabase } from "$lib/server/db"; +import { requireWorkspaceByRouteId } from "$lib/server/workspace-routing"; + +interface WorkspaceRouteEvent { + params: { workspaceId: string }; +} + +export async function loadCanonicalWorkspaceRoute( + event: WorkspaceRouteEvent, + loader: (event: any, workspaceId: string) => Promise, +): Promise { + const workspace = requireWorkspaceByRouteId(getDatabase(), event.params.workspaceId); + return await loader(event, workspace.id); +} diff --git a/apps/spark-cockpit/src/lib/server/legacy-session-redirects.test.ts b/apps/spark-cockpit/src/lib/server/legacy-session-redirects.test.ts new file mode 100644 index 00000000..c63be480 --- /dev/null +++ b/apps/spark-cockpit/src/lib/server/legacy-session-redirects.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getDatabase: vi.fn(() => ({})), + getProjectedSession: vi.fn(), + loadShellWorkspaceLayout: vi.fn(), + loadWorkspaceByRouteId: vi.fn(), +})); + +vi.mock("$lib/server/db", () => ({ getDatabase: mocks.getDatabase })); +vi.mock("$lib/server/managed-sessions", () => ({ + getProjectedManagedSessionForCockpit: mocks.getProjectedSession, +})); +vi.mock("$lib/server/shell-layout", () => ({ + loadShellWorkspaceLayout: mocks.loadShellWorkspaceLayout, +})); +vi.mock("$lib/server/workspace-routing", () => ({ + loadWorkspaceByRouteId: mocks.loadWorkspaceByRouteId, +})); + +import { load as loadLegacyList } from "../../routes/(workbench)/sessions/+page.server"; +import { load as loadLegacyDetail } from "../../routes/(workbench)/sessions/[sessionId]/+page.server"; + +describe("legacy session redirects", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadShellWorkspaceLayout.mockReturnValue({ + activeWorkspace: { id: "ws_cookie", slug: "cookie workspace", name: "Cookie" }, + }); + mocks.loadWorkspaceByRouteId.mockReturnValue({ + id: "ws_bound", + slug: "bound workspace", + name: "Bound", + }); + mocks.getProjectedSession.mockReturnValue({ + sessionId: "runtime/ops", + status: "idle", + scope: { kind: "workspace", workspaceId: "ws_bound" }, + workspaceId: "ws_bound", + }); + }); + + it("redirects list GET to the selected workspace and preserves query", async () => { + const result = await captureThrown(() => + loadLegacyList( + listEvent("http://localhost/sessions?workspace=cookie%20workspace&new=workspace") as never, + ), + ); + expect(result).toMatchObject({ + status: 303, + location: "/cookie%20workspace/sessions?workspace=cookie%20workspace&new=workspace", + }); + expect(mocks.loadShellWorkspaceLayout).toHaveBeenCalledOnce(); + }); + + it("redirects detail GET to its session-bound workspace with encoded id and query", async () => { + const parent = vi.fn(); + const result = await captureThrown(() => + loadLegacyDetail({ + params: { sessionId: "runtime/ops" }, + parent, + url: new URL("http://localhost/sessions/runtime%2Fops?tab=activity"), + } as never), + ); + expect(result).toMatchObject({ + status: 303, + location: "/bound%20workspace/sessions/runtime%2Fops?tab=activity", + }); + expect(parent).not.toHaveBeenCalled(); + expect(mocks.loadShellWorkspaceLayout).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing", null], + ["archived", { sessionId: "archived", status: "archived", workspaceId: "ws_bound" }], + ["global", { sessionId: "global", status: "idle", scope: { kind: "daemon" } }], + ])("returns 404 for %s detail without loading shell/model/rail", async (sessionId, session) => { + mocks.getProjectedSession.mockReturnValue(session); + const result = await captureThrown(() => + loadLegacyDetail({ + params: { sessionId }, + parent: vi.fn(), + url: new URL(`http://localhost/sessions/${sessionId}`), + } as never), + ); + expect(result).toMatchObject({ status: 404 }); + expect(mocks.loadShellWorkspaceLayout).not.toHaveBeenCalled(); + expect(mocks.loadWorkspaceByRouteId).not.toHaveBeenCalled(); + }); +}); + +async function captureThrown(run: () => unknown): Promise<{ status?: number; location?: string }> { + try { + await run(); + } catch (caught) { + return caught as { status?: number; location?: string }; + } + throw new Error("Expected route load to throw"); +} + +function listEvent(url: string) { + return { + cookies: { get: vi.fn() }, + locals: { workspaceId: null }, + parent: vi.fn(), + url: new URL(url), + }; +} diff --git a/apps/spark-cockpit/src/lib/server/session-conversation-actions.test.ts b/apps/spark-cockpit/src/lib/server/session-conversation-actions.test.ts index 53756f49..96deecb0 100644 --- a/apps/spark-cockpit/src/lib/server/session-conversation-actions.test.ts +++ b/apps/spark-cockpit/src/lib/server/session-conversation-actions.test.ts @@ -121,7 +121,7 @@ vi.mock("$lib/server/submission-idempotency", () => ({ `idem_${phase === "session.create" ? "1" : "2"}${submissionId.length.toString(16).padStart(31, "0")}`, })); -import { actions, load } from "../../routes/(workbench)/sessions/+page.server"; +import { actions, loadSessionsPage as load } from "./session-page-routes"; import { conversationStartSessionId } from "./conversation-submission"; const session = { diff --git a/apps/spark-cockpit/src/lib/server/session-detail-route.ts b/apps/spark-cockpit/src/lib/server/session-detail-route.ts new file mode 100644 index 00000000..11cf7a2d --- /dev/null +++ b/apps/spark-cockpit/src/lib/server/session-detail-route.ts @@ -0,0 +1,67 @@ +import { error } from "@sveltejs/kit"; +import { createId } from "@zendev-lab/spark-protocol"; +import { latestEventCursor } from "@zendev-lab/spark-cockpit-coordination/events"; +import { loadSessionActivity } from "@zendev-lab/spark-cockpit-coordination/session-activity"; +import { getDatabase } from "$lib/server/db"; +import { + getProjectedManagedSessionForCockpit, + getProjectedManagedSessionSnapshotForCockpit, +} from "$lib/server/managed-sessions"; +import { loadProjectedModelControlForCockpit } from "$lib/server/model-control"; +import { createCockpitSubmissionId } from "$lib/server/submission-idempotency"; +import { workspaceIdForWorkbenchSession } from "$lib/workbench-session-scope"; + +export interface SessionPageLoadEvent { + params: { sessionId: string }; + parent: () => Promise; + url: URL; +} + +export async function loadSessionPage( + { params, parent }: SessionPageLoadEvent, + expectedWorkspaceId?: string, +) { + const db = getDatabase(); + const eventCursor = latestEventCursor(db); + const parentData = await parent(); + const selectedFromRail = parentData.sessions.find( + (session: { sessionId: string }) => session.sessionId === params.sessionId, + ); + const projectedSelected = + selectedFromRail == null ? getProjectedManagedSessionForCockpit(params.sessionId) : null; + const selected = selectedFromRail ?? projectedSelected; + const workspaceId = selected ? workspaceIdForWorkbenchSession(selected) : null; + if (!selected || !workspaceId || workspaceId !== parentData.activeWorkspace?.id) { + throw error(404, "Session not found"); + } + if (!selectedFromRail && parentData.sessionControlAvailable) { + throw error(404, "Session not found"); + } + if (expectedWorkspaceId && workspaceId !== expectedWorkspaceId) { + throw error(404, "Session not found"); + } + + const [snapshotWindow, modelControl] = await Promise.all([ + Promise.resolve(getProjectedManagedSessionSnapshotForCockpit(params.sessionId)), + loadProjectedModelControlForCockpit({ workspaceId }), + ]); + return { + selectedSessionId: selected.sessionId, + sendSubmissionIdSeed: createId("idem"), + selectedSession: selected, + sessionSnapshot: snapshotWindow?.snapshot ?? null, + sessionHistory: snapshotWindow?.history ?? null, + sessionEventCursor: eventCursor + ? typeof eventCursor.sequence === "number" + ? `${eventCursor.sequence}|${eventCursor.createdAt}|${eventCursor.id}` + : `${eventCursor.createdAt}|${eventCursor.id}` + : null, + canAssign: parentData.sessionControlAvailable && selected.status !== "archived", + modelControl, + submissionId: createCockpitSubmissionId(), + sessionActivity: loadSessionActivity(db, { + workspaceId, + sessionId: selected.sessionId, + }), + }; +} diff --git a/apps/spark-cockpit/src/lib/server/session-page-routes.ts b/apps/spark-cockpit/src/lib/server/session-page-routes.ts new file mode 100644 index 00000000..9f98e6b6 --- /dev/null +++ b/apps/spark-cockpit/src/lib/server/session-page-routes.ts @@ -0,0 +1,651 @@ +import { error as httpError, fail, redirect } from "@sveltejs/kit"; +import { createId } from "@zendev-lab/spark-protocol"; +import { getRequestDictionary, localeCookieName } from "$lib/i18n"; +import { titleFromPrompt } from "@zendev-lab/spark-cockpit-coordination/agents-product"; +import { + cancelConversationTurnForCockpit, + submitConversationTurnForCockpit, +} from "$lib/server/conversation-control"; +import { formText } from "$lib/server/form-data"; +import { + TurnAttachmentValidationError, + attachmentPrompt, + turnAttachmentsFromFormData, +} from "$lib/server/turn-attachments"; +import { getDatabase } from "$lib/server/db"; +import { requireWorkspaceByRouteId } from "$lib/server/workspace-routing"; +import { conversationStartSessionId } from "$lib/server/conversation-submission"; +import { + archiveManagedSessionForCockpit, + createManagedSessionForCockpit, + getManagedSessionForCockpit, + getProjectedManagedSessionForCockpit, +} from "$lib/server/managed-sessions"; +import { + loadModelControlForCockpit, + loadProjectedModelControlForCockpit, + modelValue, + parseModelValue, + parseThinkingLevelValue, + setSessionModelForCockpit, + setSessionThinkingLevelForCockpit, +} from "$lib/server/model-control"; +import { + cockpitSubmissionIdempotencyKey, + createCockpitSubmissionId, +} from "$lib/server/submission-idempotency"; +import { workspaceIdForWorkbenchSession } from "$lib/workbench-session-scope"; +import { sessionHasChannelBinding } from "$lib/channel-session-title"; +import { cockpitSlashSubmissionError } from "$lib/slash-actions"; +import { workbenchSessionsPathFromPathname, workspaceSessionsPath } from "$lib/workspace-routes"; +import type { Actions } from "@sveltejs/kit"; +import type { Cookies } from "@sveltejs/kit"; + +export interface SessionsPageLoadEvent { + parent: () => Promise; + url: URL; +} + +export async function loadSessionsPage( + { parent, url }: SessionsPageLoadEvent, + expectedWorkspaceId?: string, +) { + const parentData = await parent(); + if (expectedWorkspaceId && parentData.activeWorkspace?.id !== expectedWorkspaceId) { + throw httpError(404, "Workspace not found."); + } + if (url?.pathname === "/sessions" && parentData.activeWorkspace) { + redirect(303, `${workspaceSessionsPath(parentData.activeWorkspace)}${url.search}`); + } + const workspaceId = parentData.activeWorkspace?.id ?? null; + const modelControl = workspaceId + ? parentData.sessionControlAvailable + ? await loadProjectedModelControlForCockpit({ workspaceId }).then(async (projected) => + projected.available + ? projected + : loadModelControlForCockpit({ workspaceId }).then((control) => + control.available ? control : projected, + ), + ) + : await loadProjectedModelControlForCockpit({ workspaceId }) + : { available: false, snapshot: { providers: [], diagnostics: [] } }; + return { + sessions: parentData.sessions, + sessionsAvailable: parentData.sessionsAvailable, + sessionControlAvailable: parentData.sessionControlAvailable, + selectedSessionId: null as string | null, + startSubmissionIdSeed: createId("idem"), + sessionActivity: null, + modelControl, + submissionId: createCockpitSubmissionId(), + }; +} + +interface SessionActionEvent { + cookies: Cookies; + params: Record; + request: Request; + url: URL; +} + +export const actions = { + startConversation: async ({ cookies, params, request, url }: SessionActionEvent) => { + const t = getRequestDictionary({ + cookieLocale: cookies.get(localeCookieName), + acceptLanguage: request.headers.get("accept-language"), + }).sessions; + const formData = await request.formData(); + const workspaceId = formText(formData, "workspaceId").trim(); + const message = formText(formData, "message").trim(); + const model = formText(formData, "model").trim(); + const thinkingLevel = formText(formData, "thinkingLevel").trim(); + const submittedId = formText(formData, "submissionId").trim(); + const submissionId = submittedId || createCockpitSubmissionId(); + const values = { workspaceId, message, model, thinkingLevel, submissionId }; + + if (!workspaceId) { + return fail(400, { + intent: "startConversation", + success: false, + error: t.createWorkspaceRequired, + message: t.createWorkspaceRequired, + values, + }); + } + assertRouteWorkspace(params, workspaceId, "Workspace not found."); + if (!message) { + return fail(400, { + intent: "startConversation", + success: false, + error: t.assignGoalRequired, + message: t.assignGoalRequired, + values, + }); + } + const slashActionError = cockpitSlashSubmissionError(message, t.workbench.slashActions); + if (slashActionError) { + return fail(400, { + intent: "startConversation", + success: false, + error: slashActionError, + message: slashActionError, + values, + }); + } + + let session; + let deterministicSessionId: string | undefined; + try { + deterministicSessionId = conversationStartSessionId(workspaceId, submissionId); + session = await createManagedSessionForCockpit({ + scope: { kind: "workspace", workspaceId }, + workspaceId, + ...(deterministicSessionId ? { sessionId: deterministicSessionId } : {}), + idempotencyKey: cockpitSubmissionIdempotencyKey(submissionId, "session.create"), + }); + } catch (caught) { + if (deterministicSessionId) { + const existing = await getManagedSessionForCockpit(deterministicSessionId); + if ( + existing && + existing.status !== "archived" && + workspaceIdForWorkbenchSession(existing) === workspaceId + ) { + session = existing; + } + } + if (session) { + // A previous identical HTTP request created this session before its + // response was lost. Continue with the same turn idempotency key. + } else { + const error = caught instanceof Error ? caught.message : t.createFailed; + return fail(400, { + intent: "startConversation", + success: false, + error, + message: error, + values, + }); + } + } + + try { + if (model) await setSessionModelForCockpit(session.sessionId, parseModelValue(model)); + if (thinkingLevel) { + await setSessionThinkingLevelForCockpit( + session.sessionId, + parseThinkingLevelValue(thinkingLevel), + ); + } + await submitConversationMessage({ + workspaceId, + sessionId: session.sessionId, + message, + submissionId, + }); + } catch (caught) { + // Client-provided submission ids stay recoverable for retries; server-minted + // ones are cleaned up so empty plain posts do not leave orphan sessions. + if (!submittedId) { + try { + await archiveManagedSessionForCockpit(session.sessionId); + } catch { + // Preserve the queueing failure. The session remains recoverable in the registry if + // best-effort cleanup itself fails. + } + } + const error = caught instanceof Error ? caught.message : t.assignFailed; + return fail(400, { + intent: "startConversation", + success: false, + error, + message: error, + values, + }); + } + + const sessionsPath = workbenchSessionsPathFromPathname(url?.pathname ?? "") ?? "/sessions"; + redirect(303, `${sessionsPath}/${encodeURIComponent(session.sessionId)}`); + }, + + sendMessage: async ({ cookies, params, request }: SessionActionEvent) => { + const t = getRequestDictionary({ + cookieLocale: cookies.get(localeCookieName), + acceptLanguage: request.headers.get("accept-language"), + }).sessions; + const formData = await request.formData(); + const sessionId = formText(formData, "sessionId").trim(); + const message = formText(formData, "message").trim(); + const submissionId = formText(formData, "submissionId").trim() || createCockpitSubmissionId(); + const values = { sessionId, message, submissionId }; + let attachments; + try { + attachments = await turnAttachmentsFromFormData(formData); + } catch (caught) { + const error = turnAttachmentErrorMessage(caught, t.workbench); + return fail(400, { + intent: "sendMessage", + success: false, + error, + message: error, + values, + }); + } + + if (!sessionId) { + return fail(400, { + intent: "sendMessage", + success: false, + error: t.assignSessionRequired, + message: t.assignSessionRequired, + values, + }); + } + if (!message && attachments.length === 0) { + return fail(400, { + intent: "sendMessage", + success: false, + error: t.assignGoalRequired, + message: t.assignGoalRequired, + values, + }); + } + const slashActionError = message + ? cockpitSlashSubmissionError(message, t.workbench.slashActions) + : null; + if (slashActionError) { + return fail(400, { + intent: "sendMessage", + success: false, + error: slashActionError, + message: slashActionError, + values, + }); + } + + // Sending must not wait for a redundant live `session.get` command. The + // selected conversation is already projected locally, while `turn.submit` + // is the daemon-owned admission boundary that validates current state and + // durably deduplicates the browser submission nonce. + let session; + try { + session = getProjectedManagedSessionForCockpit(sessionId); + } catch (caught) { + const error = caught instanceof Error ? caught.message : t.assignFailed; + return fail(400, { + intent: "sendMessage", + success: false, + error, + message: error, + values, + }); + } + if (!session) { + return fail(400, { + intent: "sendMessage", + success: false, + error: t.assignSessionRequired, + message: t.assignSessionRequired, + values, + }); + } + const workspaceId = workspaceIdForWorkbenchSession(session); + if (!workspaceId) { + return fail(400, { + intent: "sendMessage", + success: false, + error: t.assignSessionRequired, + message: t.assignSessionRequired, + values, + }); + } + assertRouteWorkspace(params, workspaceId, "Session not found."); + if (session.status === "archived") { + return fail(400, { + intent: "sendMessage", + success: false, + error: t.assignArchived, + message: t.assignArchived, + values, + }); + } + + try { + const prompt = attachmentPrompt(message, attachments, { + image: t.workbench.attachmentImage, + file: t.workbench.attachmentFile, + }); + const turn = await submitConversationMessage({ + workspaceId, + sessionId, + message: prompt, + attachments, + submissionId, + }); + + return { + intent: "sendMessage", + success: true, + message: t.assignQueued, + queuedTurnId: turn.turnId, + values: { sessionId, message: "", submissionId: createCockpitSubmissionId() }, + }; + } catch (caught) { + const error = caught instanceof Error ? caught.message : t.assignFailed; + return fail(400, { + intent: "sendMessage", + success: false, + error, + message: error, + values, + }); + } + }, + + cancelTurn: async ({ cookies, params, request }: SessionActionEvent) => { + const t = getRequestDictionary({ + cookieLocale: cookies.get(localeCookieName), + acceptLanguage: request.headers.get("accept-language"), + }).sessions; + const formData = await request.formData(); + const sessionId = formText(formData, "sessionId").trim(); + const turnId = formText(formData, "turnId").trim(); + const dequeue = formText(formData, "cancelIntent").trim() === "dequeue"; + const intent = dequeue ? "removeQueuedTurn" : "cancelTurn"; + const values = { sessionId, turnId }; + + if (!sessionId) { + return fail(400, { + intent, + success: false, + error: t.cancelSessionRequired, + message: t.cancelSessionRequired, + values, + }); + } + if (!turnId) { + return fail(400, { + intent, + success: false, + error: t.cancelTurnRequired, + message: t.cancelTurnRequired, + values, + }); + } + + // Cancel must not wait for a live `session.get`. Workspace admission uses + // the local projection; `turn.cancel` remains the daemon-owned gate. + let session; + try { + session = getProjectedManagedSessionForCockpit(sessionId); + } catch (caught) { + const error = caught instanceof Error ? caught.message : t.cancelTurnFailed; + return fail(400, { + intent, + success: false, + error, + message: error, + values, + }); + } + if (!session) { + return fail(400, { + intent, + success: false, + error: t.cancelSessionRequired, + message: t.cancelSessionRequired, + values, + }); + } + const workspaceId = workspaceIdForWorkbenchSession(session); + if (!workspaceId) { + return fail(400, { + intent, + success: false, + error: t.cancelSessionRequired, + message: t.cancelSessionRequired, + values, + }); + } + assertRouteWorkspace(params, workspaceId, "Session not found."); + if (session.status === "archived") { + return fail(400, { + intent, + success: false, + error: t.cancelTurnArchived, + message: t.cancelTurnArchived, + values, + }); + } + + try { + const result = await cancelConversationTurnForCockpit({ sessionId, turnId }); + if (!result.cancelRequested && result.status !== "cancelled") { + // Stop is idempotent at the conversation surface. If daemon truth has + // already converged, silently accept the stale click and let the page + // refresh clear its optimistic run state. + return { + intent, + success: true, + cancelled: false, + converged: true, + invocationStatus: result.status, + cancelledTurnId: result.turnId, + values, + }; + } + return { + intent, + success: true, + cancelled: true, + message: + dequeue && result.status === "cancelled" ? t.cancelTurnDequeued : t.cancelTurnSucceeded, + invocationStatus: result.status, + cancelledTurnId: result.turnId, + values: { sessionId, turnId: result.turnId }, + }; + } catch (caught) { + const error = caught instanceof Error ? caught.message : t.cancelTurnFailed; + return fail(400, { + intent, + success: false, + cancelled: false, + error, + message: error, + values, + }); + } + }, + + selectModel: async ({ cookies, params, request }: SessionActionEvent) => { + const t = getRequestDictionary({ + cookieLocale: cookies.get(localeCookieName), + acceptLanguage: request.headers.get("accept-language"), + }).sessions; + const formData = await request.formData(); + const sessionId = formText(formData, "sessionId").trim(); + const model = formText(formData, "model").trim(); + if (!sessionId || !model) { + return fail(400, { + intent: "selectModel", + success: false, + message: t.selectModelRequired, + }); + } + try { + const session = await getManagedSessionForCockpit(sessionId); + const workspaceId = session ? workspaceIdForWorkbenchSession(session) : null; + if (!session || !workspaceId) { + return fail(400, { + intent: "selectModel", + success: false, + message: t.selectModelRequired, + values: { sessionId, model }, + }); + } + assertRouteWorkspace(params, workspaceId, "Session not found."); + const updatedSession = await setSessionModelForCockpit(sessionId, parseModelValue(model)); + if (!updatedSession.model) { + return fail(400, { + intent: "selectModel", + success: false, + message: t.effectiveModelMissing, + values: { sessionId, model }, + }); + } + const effectiveModel = modelValue(updatedSession.model); + return { + intent: "selectModel", + success: true, + message: t.workbench.modelUpdated, + model: effectiveModel, + values: { sessionId, model: effectiveModel }, + }; + } catch { + return fail(400, { + intent: "selectModel", + success: false, + message: t.workbench.modelFailed, + values: { sessionId, model }, + }); + } + }, + + selectThinking: async ({ cookies, params, request }: SessionActionEvent) => { + const t = getRequestDictionary({ + cookieLocale: cookies.get(localeCookieName), + acceptLanguage: request.headers.get("accept-language"), + }).sessions; + const formData = await request.formData(); + const sessionId = formText(formData, "sessionId").trim(); + const thinkingLevel = formText(formData, "thinkingLevel").trim(); + if (!sessionId || !thinkingLevel) { + return fail(400, { + intent: "selectThinking", + success: false, + message: t.selectThinkingRequired, + }); + } + try { + const session = await getManagedSessionForCockpit(sessionId); + const workspaceId = session ? workspaceIdForWorkbenchSession(session) : null; + if (!session || !workspaceId) { + return fail(400, { + intent: "selectThinking", + success: false, + message: t.selectThinkingRequired, + values: { sessionId, thinkingLevel }, + }); + } + assertRouteWorkspace(params, workspaceId, "Session not found."); + const updatedSession = await setSessionThinkingLevelForCockpit( + sessionId, + parseThinkingLevelValue(thinkingLevel), + ); + const level = updatedSession.thinkingLevel ?? thinkingLevel; + return { + intent: "selectThinking", + success: true, + message: t.workbench.thinkingUpdated, + thinkingLevel: level, + values: { sessionId, thinkingLevel: level }, + }; + } catch { + return fail(400, { + intent: "selectThinking", + success: false, + message: t.workbench.thinkingFailed, + values: { sessionId, thinkingLevel }, + }); + } + }, + + archiveSession: async ({ cookies, params, request, url }: SessionActionEvent) => { + const t = getRequestDictionary({ + cookieLocale: cookies.get(localeCookieName), + acceptLanguage: request.headers.get("accept-language"), + }).sessions; + const formData = await request.formData(); + const sessionId = formText(formData, "sessionId").trim(); + + if (!sessionId) { + return fail(400, { + intent: "archiveSession", + message: t.archiveSessionRequired, + }); + } + + try { + const session = await getManagedSessionForCockpit(sessionId); + const workspaceId = session ? workspaceIdForWorkbenchSession(session) : null; + if (!session || !workspaceId) { + return fail(400, { + intent: "archiveSession", + message: t.archiveSessionRequired, + }); + } + assertRouteWorkspace(params, workspaceId, "Session not found."); + if (sessionHasChannelBinding(session)) { + return fail(409, { + intent: "archiveSession", + message: t.archiveChannelBound, + }); + } + await archiveManagedSessionForCockpit(sessionId); + } catch (caught) { + return fail(400, { + intent: "archiveSession", + message: caught instanceof Error ? caught.message : t.archiveFailed, + }); + } + + redirect(303, workbenchSessionsPathFromPathname(url?.pathname ?? "") ?? "/sessions"); + }, +} satisfies Actions; + +function assertRouteWorkspace( + params: Record | undefined, + actualWorkspaceId: string, + message: string, +): void { + const routeWorkspaceId = params?.workspaceId?.trim(); + if (!routeWorkspaceId) return; + const expectedWorkspace = requireWorkspaceByRouteId(getDatabase(), routeWorkspaceId); + if (expectedWorkspace.id !== actualWorkspaceId) { + throw httpError(404, message); + } +} + +async function submitConversationMessage(input: { + workspaceId?: string; + sessionId: string; + message: string; + submissionId?: string; + attachments?: import("@zendev-lab/spark-protocol").SparkTurnAttachment[]; +}) { + const title = titleFromPrompt(input.message); + return await submitConversationTurnForCockpit({ + workspaceId: input.workspaceId, + sessionId: input.sessionId, + prompt: input.message, + title, + ...(input.attachments?.length ? { attachments: input.attachments } : {}), + ...(input.submissionId ? { submissionId: input.submissionId } : {}), + }); +} + +function turnAttachmentErrorMessage( + caught: unknown, + copy: { + attachmentCountError: string; + attachmentSizeError: string; + attachmentTotalSizeError: string; + }, +): string { + if (!(caught instanceof TurnAttachmentValidationError)) return copy.attachmentTotalSizeError; + if (caught.code === "count") return copy.attachmentCountError; + if (caught.code === "file_size") { + return copy.attachmentSizeError.replace("{name}", caught.fileName ?? "attachment"); + } + return copy.attachmentTotalSizeError; +} diff --git a/apps/spark-cockpit/src/lib/server/workbench-layout-session-scope.test.ts b/apps/spark-cockpit/src/lib/server/workbench-layout-session-scope.test.ts index 82282618..61094e5f 100644 --- a/apps/spark-cockpit/src/lib/server/workbench-layout-session-scope.test.ts +++ b/apps/spark-cockpit/src/lib/server/workbench-layout-session-scope.test.ts @@ -70,7 +70,7 @@ beforeEach(() => { }); describe("workbench session layout scope", () => { - it("paints the rail from the projected list without waiting on live session.list", async () => { + it("resolves legacy detail through the generic session scope without a route-id branch", async () => { const url = new URL("http://localhost:5173/sessions/sess_cached"); const result = await load({ cookies: {}, @@ -81,8 +81,8 @@ describe("workbench session layout scope", () => { expect(mocks.shellLayout).toHaveBeenCalledWith( expect.objectContaining({ - pathname: "/sessions", - preferredWorkspaceId: null, + pathname: "/sessions/sess_cached", + preferredWorkspaceId: workspace.id, }), ); expect(mocks.projectedList).toHaveBeenCalledWith({ workspaceId: workspace.id }); diff --git a/apps/spark-cockpit/src/lib/server/workbench-session-page.test.ts b/apps/spark-cockpit/src/lib/server/workbench-session-page.test.ts index 39c53020..b8516dbc 100644 --- a/apps/spark-cockpit/src/lib/server/workbench-session-page.test.ts +++ b/apps/spark-cockpit/src/lib/server/workbench-session-page.test.ts @@ -37,9 +37,7 @@ vi.mock("$lib/server/model-control", () => ({ vi.mock("$lib/server/submission-idempotency", () => ({ createCockpitSubmissionId: () => "generated-browser-submission", })); -vi.mock("../../routes/(workbench)/sessions/+page.server", () => ({ actions: {} })); - -import { _loadSessionPage } from "../../routes/(workbench)/sessions/[sessionId]/+page.server"; +import { loadSessionPage as _loadSessionPage } from "./session-detail-route"; const workspaceSession = { sessionId: "sess_workspace", diff --git a/apps/spark-cockpit/src/lib/session-live-events.ts b/apps/spark-cockpit/src/lib/session-live-events.ts index 791501c0..5154aba7 100644 --- a/apps/spark-cockpit/src/lib/session-live-events.ts +++ b/apps/spark-cockpit/src/lib/session-live-events.ts @@ -452,193 +452,292 @@ export function sessionEventCursor( : `${event.sequence}|${event.createdAt}|${event.id}`; } +type DaemonEventType = SparkDaemonEvent["type"]; +type DaemonEventOf = Extract; +type DaemonEventHandler = ( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf, +) => SessionLiveEventResult; + +type ViewEvent = DaemonEventOf<"daemon.view_event">["view"]; +type ViewEventType = ViewEvent["type"]; +type ViewEventOf = Extract; +type ViewEventHandler = ( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf, +) => SessionLiveEventResult; + +const daemonEventHandlers = { + "daemon.session.updated": applySessionUpdatedEvent, + "daemon.task.lifecycle": applyTaskLifecycleEvent, + "daemon.view_event": applyViewEvent, + "daemon.interaction.request": refreshActivityForDaemonEvent, + "daemon.interaction.response": refreshActivityForDaemonEvent, + "daemon.artifact.projected": refreshActivityForDaemonEvent, +} satisfies { [K in DaemonEventType]: DaemonEventHandler }; + +const viewEventHandlers = { + "session.snapshot": applySessionSnapshotEvent, + "session.message": applySessionMessageEvent, + "run.update": applyRunUpdateEvent, + "driver.update": applyDriverUpdateEvent, + "task.update": applyTaskUpdateEvent, + "artifact.update": applyArtifactUpdateEvent, + "evidence.update": applyEvidenceUpdateEvent, +} satisfies { [K in ViewEventType]: ViewEventHandler }; + function applyDaemonEvent( state: SessionLiveEventState, event: SessionSerializedEvent, ): SessionLiveEventResult | null { if (!event.kind.startsWith("daemon.")) return null; - const daemonEvent = daemonEventFromPayload(event.payload); - if (!daemonEvent) { + if (!daemonEvent || daemonEvent.sessionId !== state.sessionId) { return { changed: false, refreshActivity: false }; } + return dispatchDaemonEvent(state, event, daemonEvent); +} - if (daemonEvent.sessionId !== state.sessionId) { - return { changed: false, refreshActivity: false }; - } +function dispatchDaemonEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: SparkDaemonEvent, +): SessionLiveEventResult { + const handler = daemonEventHandlers[daemonEvent.type] as DaemonEventHandler< + typeof daemonEvent.type + >; + return handler(state, event, daemonEvent); +} - if (daemonEvent.type === "daemon.session.updated") { - if (state.view && daemonEvent.title) { - state.view = { - ...state.view, - title: daemonEvent.title, - updatedAt: daemonEvent.emittedAt ?? event.createdAt, - }; - } - return { changed: Boolean(daemonEvent.title), refreshActivity: true }; +function applySessionUpdatedEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.session.updated">, +): SessionLiveEventResult { + if (state.view && daemonEvent.title) { + state.view = { + ...state.view, + title: daemonEvent.title, + updatedAt: daemonEvent.emittedAt ?? event.createdAt, + }; } + return { changed: Boolean(daemonEvent.title), refreshActivity: true }; +} - if (daemonEvent.type !== "daemon.view_event") { - if (daemonEvent.type === "daemon.task.lifecycle") { - const turnId = daemonEvent.invocationId ?? null; - const status = daemonEvent.status.toLocaleLowerCase(); - if ( - !turnId || - !isKnownPendingInvocation(state, turnId) || - !acceptInvocationPhase(state, turnId, status) - ) { - return { changed: false, refreshActivity: true }; - } - const previousActiveTurnId = state.activeTurnId; - const viewChanged = applyDaemonInvocationPhaseToView( - state, - turnId, - status, - daemonEvent.emittedAt ?? event.createdAt, - ); - applyInvocationCancellationTarget(state, turnId, status); - return { - changed: viewChanged || state.activeTurnId !== previousActiveTurnId, - refreshActivity: true, - }; - } - // Lifecycle / session meta events never rewrite pendingTurns or status; - // those come from `session.snapshot` / incremental view events. +function applyTaskLifecycleEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.task.lifecycle">, +): SessionLiveEventResult { + const turnId = daemonEvent.invocationId ?? null; + const status = daemonEvent.status.toLocaleLowerCase(); + if ( + !turnId || + !isKnownPendingInvocation(state, turnId) || + !acceptInvocationPhase(state, turnId, status) + ) { return { changed: false, refreshActivity: true }; } + const previousActiveTurnId = state.activeTurnId; + const viewChanged = applyDaemonInvocationPhaseToView( + state, + turnId, + status, + daemonEvent.emittedAt ?? event.createdAt, + ); + applyInvocationCancellationTarget(state, turnId, status); + return { + changed: viewChanged || state.activeTurnId !== previousActiveTurnId, + refreshActivity: true, + }; +} - const viewEvent = daemonEvent.view; - if (viewEvent.type === "session.snapshot") { - if (viewEvent.session.sessionId !== state.sessionId) { - return { changed: false, refreshActivity: false }; - } - const mailbox = viewEvent.session.mailbox ?? state.view?.mailbox; - const previous = state.view; - state.view = reconcileSessionView( - previous, - normalizeSnapshotAgainstInvocationPhases( - cloneSessionView({ - ...viewEvent.session, - ...(mailbox ? { mailbox } : {}), - }), - state.invocationPhases, - ), - state.sessionId, - { preserveCurrentHistory: true, source: "event" }, - ); - syncInvocationStateFromView(state, true); - return { changed: state.view !== previous, refreshActivity: false }; +function refreshActivityForDaemonEvent(): SessionLiveEventResult { + return { changed: false, refreshActivity: true }; +} + +function applyViewEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, +): SessionLiveEventResult { + return dispatchViewEvent(state, event, daemonEvent, daemonEvent.view); +} + +function dispatchViewEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEvent, +): SessionLiveEventResult { + const handler = viewEventHandlers[viewEvent.type] as ViewEventHandler; + return handler(state, event, daemonEvent, viewEvent); +} + +function applySessionSnapshotEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + _daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"session.snapshot">, +): SessionLiveEventResult { + if (viewEvent.session.sessionId !== state.sessionId) { + return { changed: false, refreshActivity: false }; } + const mailbox = viewEvent.session.mailbox ?? state.view?.mailbox; + const previous = state.view; + state.view = reconcileSessionView( + previous, + normalizeSnapshotAgainstInvocationPhases( + cloneSessionView({ + ...viewEvent.session, + ...(mailbox ? { mailbox } : {}), + }), + state.invocationPhases, + ), + state.sessionId, + { preserveCurrentHistory: true, source: "event" }, + ); + syncInvocationStateFromView(state, true); + return { changed: state.view !== previous, refreshActivity: false }; +} +function applySessionMessageEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"session.message">, +): SessionLiveEventResult { + if (viewEvent.sessionId !== state.sessionId) return { changed: false, refreshActivity: false }; const current = state.view ?? emptySessionView(state.sessionId, event.createdAt); - if (viewEvent.type === "session.message") { - if (viewEvent.sessionId !== state.sessionId) { - return { changed: false, refreshActivity: false }; - } - const message = correlateLiveUserMessage( - sanitizeLiveMessage(viewEvent.message), - daemonEvent.invocationId ?? null, - ); - const messages = upsertSessionMessage(current.messages, message, state.sessionId, "ordered"); - if (messages === current.messages) { - return { changed: false, refreshActivity: false }; - } - state.view = { - ...current, - messages, - updatedAt: message.updatedAt ?? message.createdAt ?? event.createdAt, - }; - return { changed: true, refreshActivity: false }; - } - if (viewEvent.type === "run.update") { - if (viewEvent.sessionId && viewEvent.sessionId !== state.sessionId) { - return { changed: false, refreshActivity: false }; - } - state.view = { - ...current, - // A run card may describe a nested workflow, role or task. It is useful - // inspector data, but it does not own the conversation execution state. - // Only daemon lifecycle / pending-turn truth may drive the header spinner - // and Stop target. - runs: upsertById(current.runs, viewEvent.run), - updatedAt: - viewEvent.run.completedAt ?? - viewEvent.run.startedAt ?? - daemonEvent.emittedAt ?? - event.createdAt, - }; - return { changed: true, refreshActivity: false }; - } - if (viewEvent.type === "driver.update") { - if (viewEvent.sessionId !== state.sessionId) { - return { changed: false, refreshActivity: false }; - } - state.view = { - ...current, - drivers: upsertByKey(current.drivers ?? [], viewEvent.driver, (driver) => driver.driverId), - updatedAt: daemonEvent.emittedAt ?? event.createdAt, - }; - return { changed: true, refreshActivity: false }; - } - if (viewEvent.type === "task.update") { - state.view = { - ...current, - tasks: upsertByKey(current.tasks, viewEvent.task, (task) => task.ref), - updatedAt: daemonEvent.emittedAt ?? event.createdAt, - }; - return { changed: true, refreshActivity: false }; - } - if (viewEvent.type === "artifact.update") { - const kind = viewEvent.artifact.kind; - if (kind === "issue" || kind === "pr" || kind === "preview") { - state.view = { - ...current, - artifacts: upsertByKey(current.artifacts, viewEvent.artifact, (artifact) => artifact.ref), - updatedAt: viewEvent.artifact.updatedAt ?? viewEvent.artifact.createdAt ?? event.createdAt, - }; - return { changed: true, refreshActivity: false }; - } - // Legacy artifact.update for evidence kinds — land in the evidence lane. - const evidence = { - version: viewEvent.artifact.version, - ref: viewEvent.artifact.ref, - title: viewEvent.artifact.title, - kind: - kind === "document" || kind === "record" || kind === "trace" || kind === "knowledge" - ? kind - : ("other" as const), - format: - viewEvent.artifact.format === "markdown" || - viewEvent.artifact.format === "json" || - viewEvent.artifact.format === "text" || - viewEvent.artifact.format === "blob" - ? viewEvent.artifact.format - : ("other" as const), - ...(viewEvent.artifact.status ? { status: viewEvent.artifact.status } : {}), - ...(viewEvent.artifact.producer ? { producer: viewEvent.artifact.producer } : {}), - ...(viewEvent.artifact.createdAt ? { createdAt: viewEvent.artifact.createdAt } : {}), - ...(viewEvent.artifact.updatedAt ? { updatedAt: viewEvent.artifact.updatedAt } : {}), - ...(viewEvent.artifact.preview ? { preview: viewEvent.artifact.preview } : {}), - metadata: viewEvent.artifact.metadata, - }; - state.view = { - ...current, - evidence: upsertByKey(current.evidence ?? [], evidence, (item) => item.ref), - updatedAt: evidence.updatedAt ?? evidence.createdAt ?? event.createdAt, - }; - return { changed: true, refreshActivity: false }; + const message = correlateLiveUserMessage( + sanitizeLiveMessage(viewEvent.message), + daemonEvent.invocationId ?? null, + ); + const messages = upsertSessionMessage(current.messages, message, state.sessionId, "ordered"); + if (messages === current.messages) return { changed: false, refreshActivity: false }; + state.view = { + ...current, + messages, + updatedAt: message.updatedAt ?? message.createdAt ?? event.createdAt, + }; + return { changed: true, refreshActivity: false }; +} + +function applyRunUpdateEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"run.update">, +): SessionLiveEventResult { + if (viewEvent.sessionId && viewEvent.sessionId !== state.sessionId) { + return { changed: false, refreshActivity: false }; } - if (viewEvent.type === "evidence.update") { + const current = state.view ?? emptySessionView(state.sessionId, event.createdAt); + state.view = { + ...current, + runs: upsertById(current.runs, viewEvent.run), + updatedAt: + viewEvent.run.completedAt ?? + viewEvent.run.startedAt ?? + daemonEvent.emittedAt ?? + event.createdAt, + }; + return { changed: true, refreshActivity: false }; +} + +function applyDriverUpdateEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"driver.update">, +): SessionLiveEventResult { + if (viewEvent.sessionId !== state.sessionId) return { changed: false, refreshActivity: false }; + const current = state.view ?? emptySessionView(state.sessionId, event.createdAt); + state.view = { + ...current, + drivers: upsertByKey(current.drivers ?? [], viewEvent.driver, (driver) => driver.driverId), + updatedAt: daemonEvent.emittedAt ?? event.createdAt, + }; + return { changed: true, refreshActivity: false }; +} + +function applyTaskUpdateEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"task.update">, +): SessionLiveEventResult { + const current = state.view ?? emptySessionView(state.sessionId, event.createdAt); + state.view = { + ...current, + tasks: upsertByKey(current.tasks, viewEvent.task, (task) => task.ref), + updatedAt: daemonEvent.emittedAt ?? event.createdAt, + }; + return { changed: true, refreshActivity: false }; +} + +function applyArtifactUpdateEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + _daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"artifact.update">, +): SessionLiveEventResult { + const current = state.view ?? emptySessionView(state.sessionId, event.createdAt); + const kind = viewEvent.artifact.kind; + if (kind === "issue" || kind === "pr" || kind === "preview") { state.view = { ...current, - evidence: upsertByKey(current.evidence ?? [], viewEvent.evidence, (item) => item.ref), - updatedAt: viewEvent.evidence.updatedAt ?? viewEvent.evidence.createdAt ?? event.createdAt, + artifacts: upsertByKey(current.artifacts, viewEvent.artifact, (artifact) => artifact.ref), + updatedAt: viewEvent.artifact.updatedAt ?? viewEvent.artifact.createdAt ?? event.createdAt, }; return { changed: true, refreshActivity: false }; } - const _exhaustive: never = viewEvent; - void _exhaustive; - return { changed: false, refreshActivity: false }; + const evidence = { + version: viewEvent.artifact.version, + ref: viewEvent.artifact.ref, + title: viewEvent.artifact.title, + kind: + kind === "document" || kind === "record" || kind === "trace" || kind === "knowledge" + ? kind + : ("other" as const), + format: + viewEvent.artifact.format === "markdown" || + viewEvent.artifact.format === "json" || + viewEvent.artifact.format === "text" || + viewEvent.artifact.format === "blob" + ? viewEvent.artifact.format + : ("other" as const), + ...(viewEvent.artifact.status ? { status: viewEvent.artifact.status } : {}), + ...(viewEvent.artifact.producer ? { producer: viewEvent.artifact.producer } : {}), + ...(viewEvent.artifact.createdAt ? { createdAt: viewEvent.artifact.createdAt } : {}), + ...(viewEvent.artifact.updatedAt ? { updatedAt: viewEvent.artifact.updatedAt } : {}), + ...(viewEvent.artifact.preview ? { preview: viewEvent.artifact.preview } : {}), + metadata: viewEvent.artifact.metadata, + }; + state.view = { + ...current, + evidence: upsertByKey(current.evidence ?? [], evidence, (item) => item.ref), + updatedAt: evidence.updatedAt ?? evidence.createdAt ?? event.createdAt, + }; + return { changed: true, refreshActivity: false }; +} + +function applyEvidenceUpdateEvent( + state: SessionLiveEventState, + event: SessionSerializedEvent, + _daemonEvent: DaemonEventOf<"daemon.view_event">, + viewEvent: ViewEventOf<"evidence.update">, +): SessionLiveEventResult { + const current = state.view ?? emptySessionView(state.sessionId, event.createdAt); + state.view = { + ...current, + evidence: upsertByKey(current.evidence ?? [], viewEvent.evidence, (item) => item.ref), + updatedAt: viewEvent.evidence.updatedAt ?? viewEvent.evidence.createdAt ?? event.createdAt, + }; + return { changed: true, refreshActivity: false }; } function applyDaemonInvocationPhaseToView( diff --git a/apps/spark-cockpit/src/lib/session-timeline.ts b/apps/spark-cockpit/src/lib/session-timeline.ts index 3f69f0ae..5f51cc55 100644 --- a/apps/spark-cockpit/src/lib/session-timeline.ts +++ b/apps/spark-cockpit/src/lib/session-timeline.ts @@ -8,6 +8,7 @@ import { preferToolSummary, textConversationPart, } from "./components/conversation/conversation-view"; +import { mergeToolParts } from "./components/conversation/conversation-part-converters"; import type { ConversationApprovalState, ConversationMessageView, @@ -188,12 +189,33 @@ export function activeSessionTimelineProcessItemId( ); } -export function buildSessionTimeline(input: { +type SessionTimelineInput = { messages: SparkMessageView[]; commands: SessionTimelineCommand[]; reports: SessionTimelineReport[]; fallbackTimestamp: string; -}): SessionTimelineItem[] { +}; + +type CanonicalTimelineProjection = { + items: SessionTimelineItem[]; + canonicalMessageIds: Set; + canonicalUserInvocationIds: Set; + canonicalFallbackMatches: Map; +}; + +type CommandTimelineProjection = { + items: SessionTimelineItem[]; + fallbackSubmittedMessages: Map; +}; + +export function buildSessionTimeline(input: SessionTimelineInput): SessionTimelineItem[] { + const canonical = projectCanonicalMessages(input); + const commands = projectFallbackCommands(input, canonical); + const reports = projectReports(input, canonical, commands); + return mergeTimelineItems(sortTimelineItems([...canonical.items, ...commands.items, ...reports])); +} + +function projectCanonicalMessages(input: SessionTimelineInput): CanonicalTimelineProjection { const items: SessionTimelineItem[] = []; const canonicalMessageIds = new Set(); const canonicalUserInvocationIds = new Set(); @@ -236,6 +258,15 @@ export function buildSessionTimeline(input: { }); } + return { items, canonicalMessageIds, canonicalUserInvocationIds, canonicalFallbackMatches }; +} + +function projectFallbackCommands( + input: SessionTimelineInput, + canonical: CanonicalTimelineProjection, +): CommandTimelineProjection { + const items: SessionTimelineItem[] = []; + const { canonicalMessageIds } = canonical; // Assignment commands predate the daemon-owned native transcript. They do not // carry a canonical message ID, so they cannot be reconciled safely once a // session snapshot exists. Keep them only as an empty-snapshot compatibility @@ -260,6 +291,17 @@ export function buildSessionTimeline(input: { } } + return { items, fallbackSubmittedMessages }; +} + +function projectReports( + input: SessionTimelineInput, + canonical: CanonicalTimelineProjection, + commands: CommandTimelineProjection, +): SessionTimelineItem[] { + const items: SessionTimelineItem[] = []; + const { canonicalMessageIds, canonicalUserInvocationIds, canonicalFallbackMatches } = canonical; + const { fallbackSubmittedMessages } = commands; for (const [reportIndex, report] of latestStableReports(input.reports).entries()) { if ( report.kind === "daemon.task.lifecycle" || @@ -331,15 +373,23 @@ export function buildSessionTimeline(input: { }); } + return items; +} + +function sortTimelineItems(items: SessionTimelineItem[]): SessionTimelineItem[] { const sortedItems = items.sort((left, right) => { const time = Date.parse(left.timestamp) - Date.parse(right.timestamp); if (Number.isFinite(time) && time !== 0) return time; const lexical = left.timestamp.localeCompare(right.timestamp); return lexical || left.order - right.order || left.id.localeCompare(right.id); }); + return sortedItems; +} + +function mergeTimelineItems(items: SessionTimelineItem[]): SessionTimelineItem[] { return mergeTimelineThinkingChains( mergeConsecutiveSparkMessages( - foldRuntimeControlTurns(mergeTimelineInteractionParts(mergeTimelineToolParts(sortedItems))), + foldRuntimeControlTurns(mergeTimelineInteractionParts(mergeTimelineToolParts(items))), ), ); } @@ -643,29 +693,8 @@ function mergeTimelineThinkingChains(items: SessionTimelineItem[]) { }); } -function mergeToolsInParts(parts: ConversationPart[]) { - const result: ConversationPart[] = []; - const toolIndexes = new Map(); - for (const part of parts) { - if (part.type !== "tool") { - result.push(part); - continue; - } - const previousIndex = toolIndexes.get(part.callId); - const previous = previousIndex === undefined ? undefined : result[previousIndex]; - if (previousIndex === undefined || previous?.type !== "tool") { - toolIndexes.set(part.callId, result.length); - result.push(part); - continue; - } - result[previousIndex] = { - ...previous, - name: part.name || previous.name, - state: laterToolState(previous.state, part.state), - summary: preferToolSummary(previous.summary, part.summary, previous.state, part.state), - }; - } - return result; +function mergeToolsInParts(parts: ConversationPart[]): ConversationPart[] { + return mergeToolParts(parts); } function laterMessageStatus(previous: string | null, next: string | null): string | null { diff --git a/apps/spark-cockpit/src/lib/shell/shell-nav-link.css b/apps/spark-cockpit/src/lib/shell/shell-nav-link.css new file mode 100644 index 00000000..f863afdc --- /dev/null +++ b/apps/spark-cockpit/src/lib/shell/shell-nav-link.css @@ -0,0 +1,68 @@ +.shell-nav-link { + align-items: center; + border-radius: var(--rounded-md); + color: var(--color-ink-muted); + display: flex; + font-size: 13px; + gap: 10px; + min-height: 40px; + padding: 0 10px; + text-decoration: none; +} + +.shell-nav-link:hover { + background: var(--color-surface-soft); + color: var(--color-ink); +} + +.shell-nav-link.active { + background: var(--color-primary-weak); + color: var(--color-primary); + font-weight: 600; +} + +.shell-mobile-navigation-backdrop { + display: none; +} + +@media (max-width: 900px) { + .shell-mobile-navigation { + border-right: 1px solid var(--color-border); + box-shadow: var(--shadow-popover); + height: calc(100dvh - var(--shell-topbar-height)); + inset: var(--shell-topbar-height) auto 0 0; + max-width: min(var(--shell-mobile-navigation-width), 88vw); + opacity: 0; + position: fixed; + transform: translateX(-100%); + transition: + opacity var(--motion-default) ease, + transform var(--motion-default) ease, + visibility var(--motion-default) ease; + visibility: hidden; + width: min(var(--shell-mobile-navigation-width), 88vw); + z-index: 55; + } + + .shell-mobile-navigation.mobile-open { + opacity: 1; + transform: translateX(0); + visibility: visible; + } + + .shell-mobile-navigation-backdrop { + background: rgb(15 23 42 / 24%); + border: 0; + display: block; + inset: var(--shell-topbar-height) 0 0; + padding: 0; + position: fixed; + z-index: 50; + } +} + +@media (prefers-reduced-motion: reduce) { + .shell-mobile-navigation { + transition: none; + } +} diff --git a/apps/spark-cockpit/src/lib/workbench-nav.test.ts b/apps/spark-cockpit/src/lib/workbench-nav.test.ts index 017c17e2..39d3ad47 100644 --- a/apps/spark-cockpit/src/lib/workbench-nav.test.ts +++ b/apps/spark-cockpit/src/lib/workbench-nav.test.ts @@ -5,6 +5,7 @@ import { isWorkbenchNavItemActive, settingsHubHref, workspaceSwitcherHref, + workspaceSwitcherHrefForPage, type WorkbenchNavLabels, type WorkbenchPageLabels, } from "./workbench-nav"; @@ -120,6 +121,23 @@ describe("workbench nav", () => { ).toBe("/other/inbox"); }); + it("adapts page URL and workspace records to the switcher contract", () => { + expect( + workspaceSwitcherHrefForPage({ + url: new URL("http://127.0.0.1:5173/local/inbox"), + activeWorkspacePath: "/local", + workspacePath, + })({ slug: "other" }), + ).toBe("/other/inbox"); + expect( + workspaceSwitcherHrefForPage({ + url: new URL("http://127.0.0.1:5173/settings/models"), + activeWorkspacePath: "/local", + workspacePath, + })({ slug: "other" }), + ).toBe("/settings/models?workspace=other"); + }); + it("builds one settings hub link while preserving the active workspace context", () => { expect(settingsHubHref("local")).toBe("/settings?workspace=local"); expect(settingsHubHref("workspace with spaces")).toBe( diff --git a/apps/spark-cockpit/src/lib/workbench-nav.ts b/apps/spark-cockpit/src/lib/workbench-nav.ts index ea054fbe..01f0fa89 100644 --- a/apps/spark-cockpit/src/lib/workbench-nav.ts +++ b/apps/spark-cockpit/src/lib/workbench-nav.ts @@ -101,6 +101,21 @@ export function settingsHubHref(activeWorkspaceSlug?: string | null): string { return `/settings?${search.toString()}`; } +export function workspaceSwitcherHrefForPage(input: { + url: Pick; + activeWorkspacePath: string; + workspacePath: (workspace: { slug: string }, suffix?: string) => string; +}): (targetWorkspace: { slug: string }) => string { + return (targetWorkspace) => + workspaceSwitcherHref({ + pathname: input.url.pathname, + origin: input.url.origin, + activeWorkspacePath: input.activeWorkspacePath, + targetWorkspaceSlug: targetWorkspace.slug, + workspacePath: input.workspacePath, + }); +} + export function workspaceSwitcherHref(input: { pathname: string; origin: string; diff --git a/apps/spark-cockpit/src/routes/(console)/+layout.svelte b/apps/spark-cockpit/src/routes/(console)/+layout.svelte index c2fa972e..94dde81b 100644 --- a/apps/spark-cockpit/src/routes/(console)/+layout.svelte +++ b/apps/spark-cockpit/src/routes/(console)/+layout.svelte @@ -8,9 +8,10 @@ isConsoleNavItemActive, isControlPlanePath, } from "$lib/console-nav"; + import "$lib/shell/shell-nav-link.css"; import CockpitTopbar from "$lib/shell/CockpitTopbar.svelte"; import type { CockpitSearchSession } from "$lib/shell/cockpit-search"; - import { workspaceSwitcherHref as buildWorkspaceSwitcherHref } from "$lib/workbench-nav"; + import { workspaceSwitcherHrefForPage } from "$lib/workbench-nav"; import { workspacePath } from "$lib/workspace-routes"; let { data, children } = $props(); @@ -66,15 +67,9 @@ return isConsoleNavItemActive({ pathname: page.url.pathname, href }); } - function workspaceSwitcherHref(workspace: { slug: string }) { - return buildWorkspaceSwitcherHref({ - pathname: page.url.pathname, - origin: page.url.origin, - activeWorkspacePath, - targetWorkspaceSlug: workspace.slug, - workspacePath, - }); - } + let workspaceSwitcherHref = $derived( + workspaceSwitcherHrefForPage({ url: page.url, activeWorkspacePath, workspacePath }), + ); function closeMobileNavigation() { mobileNavigationOpen = false; @@ -105,7 +100,7 @@
{#if mobileNavigationOpen} - - - - - + diff --git a/apps/spark-cockpit/src/routes/(workbench)/+layout.server.ts b/apps/spark-cockpit/src/routes/(workbench)/+layout.server.ts index bf23ce86..213263f0 100644 --- a/apps/spark-cockpit/src/routes/(workbench)/+layout.server.ts +++ b/apps/spark-cockpit/src/routes/(workbench)/+layout.server.ts @@ -27,7 +27,7 @@ import type { LayoutServerLoad } from "./$types"; */ const WORKBENCH_SESSION_LIST_TIMEOUT_MS = 800; -export const load: LayoutServerLoad = async ({ cookies, locals, url, params, route }) => { +export const load: LayoutServerLoad = async ({ cookies, locals, url, params }) => { const workspaceIdParam = params.workspaceId ?? null; // /:workspaceId/... — key only on workspaceId so session switches stay cheap. if (workspaceIdParam) { @@ -39,19 +39,6 @@ export const load: LayoutServerLoad = async ({ cookies, locals, url, params, rou }); } - // Legacy /sessions and /sessions/[sessionId] share one rail. Key on route.id - // (stable across session switches) instead of url.pathname. - const legacySessionsRoute = - route.id === "/(workbench)/sessions" || route.id === "/(workbench)/sessions/[sessionId]"; - if (legacySessionsRoute) { - return loadLegacySessionsShell({ - cookies, - protocol: url.protocol, - preferredWorkspaceSlug: url.searchParams.get("workspace") ?? null, - authorizedWorkspaceId: locals?.workspaceId ?? null, - }); - } - const isWorkspaceDirectory = url.pathname === "/"; const selectedSessionId = workbenchSessionIdFromPath(url.pathname); const sessionsPath = workbenchSessionsPathFromPathname(url.pathname); @@ -138,43 +125,6 @@ async function loadWorkspaceRailShell(input: { }; } -async function loadLegacySessionsShell(input: { - cookies: Parameters[0]["cookies"]; - protocol: string; - preferredWorkspaceSlug: string | null; - authorizedWorkspaceId: string | null; -}) { - // Pathname is intentionally the stable `/sessions` prefix so shell resolution - // does not depend on the selected session id. - const layout = loadShellWorkspaceLayout({ - cookies: input.cookies, - pathname: "/sessions", - protocol: input.protocol, - preferredWorkspaceId: null, - preferredWorkspaceSlug: input.preferredWorkspaceSlug, - authorizedWorkspaceId: input.authorizedWorkspaceId, - }); - const activeWorkspaceId = layout.activeWorkspace?.id ?? null; - const managedSessions = activeWorkspaceId - ? await loadWorkbenchManagedSessions(activeWorkspaceId) - : { available: true, controlAvailable: false, sessions: [] as never[] }; - const db = getDatabase(); - const sessions = loadConversationSummaries( - db, - workspaceSessionsForWorkbench(managedSessions.sessions, activeWorkspaceId), - ); - const pendingAsk = layout.activeWorkspace - ? loadPendingWorkbenchAsk(db, layout.activeWorkspace.id) - : null; - return { - ...layout, - pendingAsk, - sessions, - sessionsAvailable: managedSessions.available, - sessionControlAvailable: managedSessions.controlAvailable, - }; -} - async function loadWorkbenchManagedSessions(workspaceId: string) { const projected = listProjectedManagedSessionsForCockpit({ workspaceId }); // Prefer the local rail for every workbench navigation/invalidation. Waiting diff --git a/apps/spark-cockpit/src/routes/(workbench)/+layout.svelte b/apps/spark-cockpit/src/routes/(workbench)/+layout.svelte index eb5ae01f..c173a461 100644 --- a/apps/spark-cockpit/src/routes/(workbench)/+layout.svelte +++ b/apps/spark-cockpit/src/routes/(workbench)/+layout.svelte @@ -9,13 +9,14 @@ shouldInvalidatePendingAsk, } from "$lib/pending-ask"; import WorkbenchSessionRail from "$lib/WorkbenchSessionRail.svelte"; + import "$lib/shell/shell-nav-link.css"; import CockpitTopbar from "$lib/shell/CockpitTopbar.svelte"; import type { CockpitSearchSession } from "$lib/shell/cockpit-search"; import { buildWorkbenchNavItems, isWorkbenchNavItemActive, settingsHubHref, - workspaceSwitcherHref as buildWorkspaceSwitcherHref, + workspaceSwitcherHrefForPage, } from "$lib/workbench-nav"; import { workbenchSessionIdFromPath, workspacePath } from "$lib/workspace-routes"; import { @@ -123,15 +124,9 @@ }); } - function workspaceSwitcherHref(workspace: { slug: string }) { - return buildWorkspaceSwitcherHref({ - pathname: page.url.pathname, - origin: page.url.origin, - activeWorkspacePath, - targetWorkspaceSlug: workspace.slug, - workspacePath, - }); - } + let workspaceSwitcherHref = $derived( + workspaceSwitcherHrefForPage({ url: page.url, activeWorkspacePath, workspacePath }), + ); function closeMobileSidebar() { mobileSidebarOpen = false; @@ -182,7 +177,7 @@ {#if !isWorkspaceDirectory} {#if mobileSidebarOpen} - - - - - +